Passed
Pull Request — develop (#302)
by Michiel
04:25
created

CookieService::shouldSkip2faAuthentication()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 42
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 22
nc 5
nop 4
dl 0
loc 42
rs 9.2568
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 Exception;
22
use Psr\Log\LoggerInterface;
23
use Surfnet\StepupBundle\Service\SecondFactorTypeService;
24
use Surfnet\StepupGateway\GatewayBundle\Entity\ServiceProvider;
25
use Surfnet\StepupGateway\GatewayBundle\Exception\RuntimeException;
26
use Surfnet\StepupGateway\GatewayBundle\Saml\ResponseContext;
27
use Surfnet\StepupGateway\GatewayBundle\Service\InstitutionConfigurationService;
28
use Surfnet\StepupGateway\GatewayBundle\Service\SecondFactorService;
29
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\DateTime\ExpirationHelperInterface;
30
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\Exception\CookieNotFoundException;
31
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\Exception\DecryptionFailedException;
32
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\Exception\InvalidAuthenticationTimeException;
33
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\Http\CookieHelperInterface;
34
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\ValueObject\CookieValue;
35
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\ValueObject\CookieValueInterface;
36
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\ValueObject\NullCookieValue;
37
use Symfony\Component\HttpFoundation\Request;
38
use Symfony\Component\HttpFoundation\Response;
39
40
/**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
41
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects) | Coupling is high as we are integrating logic into the infrastructure
42
 */
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...
43
class CookieService implements CookieServiceInterface
44
{
45
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
46
     * @var CookieHelperInterface
47
     */
48
    private $cookieHelper;
0 ignored issues
show
Coding Style introduced by
Private member variable "cookieHelper" must be prefixed with an underscore
Loading history...
49
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
50
     * @var InstitutionConfigurationService
51
     */
52
    private $institutionConfigurationService;
0 ignored issues
show
Coding Style introduced by
Private member variable "institutionConfigurationService" must be prefixed with an underscore
Loading history...
53
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
54
     * @var LoggerInterface
55
     */
56
    private $logger;
0 ignored issues
show
Coding Style introduced by
Private member variable "logger" must be prefixed with an underscore
Loading history...
57
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
58
     * @var SecondFactorService
59
     */
60
    private $secondFactorService;
0 ignored issues
show
Coding Style introduced by
Private member variable "secondFactorService" must be prefixed with an underscore
Loading history...
61
62
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
63
     * @var SecondFactorTypeService
64
     */
65
    private $secondFactorTypeService;
0 ignored issues
show
Coding Style introduced by
Private member variable "secondFactorTypeService" must be prefixed with an underscore
Loading history...
66
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
67
     * @var ExpirationHelperInterface
68
     */
69
    private $expirationHelper;
0 ignored issues
show
Coding Style introduced by
Private member variable "expirationHelper" must be prefixed with an underscore
Loading history...
70
71
    public function __construct(
0 ignored issues
show
Coding Style introduced by
Missing doc comment for function __construct()
Loading history...
72
        CookieHelperInterface $cookieHelper,
73
        InstitutionConfigurationService $institutionConfigurationService,
74
        SecondFactorService $secondFactorService,
75
        SecondFactorTypeService $secondFactorTypeService,
76
        ExpirationHelperInterface $expirationHelper,
77
        LoggerInterface $logger
78
    ) {
79
        $this->cookieHelper = $cookieHelper;
80
        $this->institutionConfigurationService = $institutionConfigurationService;
81
        $this->secondFactorService = $secondFactorService;
82
        $this->secondFactorTypeService = $secondFactorTypeService;
83
        $this->expirationHelper = $expirationHelper;
84
        $this->logger = $logger;
85
    }
86
87
    public function handleSsoOn2faCookieStorage(
0 ignored issues
show
Coding Style introduced by
Missing doc comment for function handleSsoOn2faCookieStorage()
Loading history...
88
        ResponseContext $responseContext,
89
        Request $request,
90
        Response $httpResponse
91
    ): Response {
92
        // Check if this specific SP is configured to allow setting of an SSO on 2FA cookie (configured in MW config)
93
        $remoteSp = $this->getRemoteSp($responseContext);
94
        if (!$remoteSp->allowedToSetSsoCookieOn2fa()) {
95
            $this->logger->notice(
96
                sprintf(
97
                    'SP: %s does not allow writing SSO on 2FA cookies',
98
                    $remoteSp->getEntityId()
99
                )
100
            );
101
            return $httpResponse;
102
        }
103
104
        // The second factor id is read from state. It is the SF Id of the token used during authentication
105
        $secondFactorId = $responseContext->getSelectedSecondFactor();
106
107
        // We can only set an SSO on 2FA cookie if a second factor authentication is being handled.
108
        if ($secondFactorId) {
109
            $secondFactor = $this->secondFactorService->findByUuid($secondFactorId);
110
            if (!$secondFactor) {
111
                throw new RuntimeException(sprintf('Second Factor token not found with ID: %s', $secondFactorId));
112
            }
113
            // Test if the institution of the Identity this SF belongs to has SSO on 2FA enabled
114
            $isEnabled = $this->institutionConfigurationService->ssoOn2faEnabled($secondFactor->institution);
115
            $this->logger->notice(
116
                sprintf(
117
                    'SSO on 2FA is %senabled for %s',
118
                    $isEnabled ? '' : 'not ',
119
                    $secondFactor->institution
120
                )
121
            );
122
123
            if ($isEnabled) {
124
                $identityId = $responseContext->getIdentityNameId();
125
                $loa = $secondFactor->getLoaLevel($this->secondFactorTypeService);
126
                $isVerifiedBySsoOn2faCookie = $responseContext->isVerifiedBySsoOn2faCookie();
127
                // Did the user perform a new second factor authentication?
128
                if (!$isVerifiedBySsoOn2faCookie) {
129
                    $cookie = CookieValue::from($identityId, $secondFactor->secondFactorId, $loa);
130
                    $this->store($httpResponse, $cookie);
131
                }
132
            }
133
        }
134
        $responseContext->finalizeAuthentication();
135
        return $httpResponse;
136
    }
137
138
    /**
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 $request should have a doc-comment as per coding-style.
Loading history...
139
     * Allow high cyclomatic complexity in favour of keeping this method readable
140
     * @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...
141
     * @SuppressWarnings(PHPMD.NPathComplexity)
142
     */
0 ignored issues
show
Coding Style introduced by
Missing @return tag in function comment
Loading history...
143
    public function shouldSkip2faAuthentication(
144
        ResponseContext $responseContext,
145
        float $requiredLoa,
146
        string $identityNameId,
147
        Request $request
148
    ): bool {
149
        if ($responseContext->isForceAuthn()) {
150
            $this->logger->notice('Ignoring SSO on 2FA cookie when ForceAuthN is specified.');
151
            return false;
152
        }
153
        $remoteSp = $this->getRemoteSp($responseContext);
154
        // Test if the SP allows SSO on 2FA to take place (configured in MW config)
155
        if (!$remoteSp->allowSsoOn2fa()) {
156
            $this->logger->notice(
157
                sprintf(
158
                    'Ignoring SSO on 2FA for SP: %s',
159
                    $remoteSp->getEntityId()
160
                )
161
            );
162
            return false;
163
        }
164
165
        $ssoCookie = $this->read($request);
166
        // Perform validation on the cookie and its contents
167
        if (!$this->isCookieValid($ssoCookie, $requiredLoa, $identityNameId)) {
168
            return false;
169
        }
170
171
        $secondFactor = $this->secondFactorService->findByUuid($ssoCookie->secondFactorId());
172
        if (!$this->secondFactorService->findByUuid($ssoCookie->secondFactorId())) {
173
            $this->logger->notice(
174
                'The second factor stored in the SSO cookie was revoked or has otherwise became unknown to Gateway',
175
                [
176
                    'secondFactorIdFromCookie' => $ssoCookie->secondFactorId()
177
                ]
178
            );
179
            return false;
180
        }
181
182
        $this->logger->notice('Verified the current 2FA authentication can be given with the SSO on 2FA cookie');
183
        $responseContext->saveSelectedSecondFactor($secondFactor);
0 ignored issues
show
Bug introduced by
It seems like $secondFactor can also be of type null; however, parameter $secondFactor of Surfnet\StepupGateway\Ga...eSelectedSecondFactor() does only seem to accept Surfnet\StepupGateway\Ga...dle\Entity\SecondFactor, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

183
        $responseContext->saveSelectedSecondFactor(/** @scrutinizer ignore-type */ $secondFactor);
Loading history...
184
        return true;
185
    }
186
187
    public function getCookieFingerprint(Request $request): string
0 ignored issues
show
Coding Style introduced by
Missing doc comment for function getCookieFingerprint()
Loading history...
188
    {
189
        return $this->cookieHelper->fingerprint($request);
190
    }
191
192
    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...
193
    {
194
        $this->cookieHelper->write($response, $cookieValue);
195
    }
196
197
    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...
198
    {
199
        try {
200
            return $this->cookieHelper->read($request);
201
        } catch (CookieNotFoundException $e) {
202
            $this->logger->notice('The SSO on 2FA cookie is not found in the request header');
203
            return new NullCookieValue();
204
        } catch (DecryptionFailedException $e) {
205
            $this->logger->notice('Decryption of the SSO on 2FA cookie failed');
206
            return new NullCookieValue();
207
        } catch (Exception $e) {
208
            $this->logger->notice(
209
                'Decryption failed, see original message in context',
210
                ['original-exception-message' => $e->getMessage()]
211
            );
212
            return new NullCookieValue();
213
        }
214
    }
215
216
    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...
217
    {
218
        $remoteSp = $responseContext->getServiceProvider();
219
        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...
220
            throw new RuntimeException('SP not found in the response context, unable to continue with SSO on 2FA');
221
        }
222
        return $remoteSp;
223
    }
224
225
    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...
226
    {
227
        if ($ssoCookie instanceof NullCookieValue) {
228
            return false;
229
        }
230
        if ($ssoCookie instanceof CookieValue && !$ssoCookie->meetsRequiredLoa($requiredLoa)) {
231
            $this->logger->notice(
232
                sprintf(
233
                    'The required LoA %d did not match the LoA of the SSO cookie %d',
234
                    $requiredLoa,
235
                    $ssoCookie->getLoa()
236
                )
237
            );
238
            return false;
239
        }
240
        if ($ssoCookie instanceof CookieValue && !$ssoCookie->issuedTo($identityNameId)) {
241
            $this->logger->notice(
242
                sprintf(
243
                    'The SSO on 2FA cookie was not issued to %s, but to %s',
244
                    $identityNameId,
245
                    $ssoCookie->getIdentityId()
246
                )
247
            );
248
            return false;
249
        }
250
        try {
251
            $isExpired = $this->expirationHelper->isExpired($ssoCookie);
252
            if ($isExpired) {
253
                $this->logger->notice(
254
                    'The SSO on 2FA cookie has expired. Meaning [authentication time] + [cookie lifetime] is in the past'
255
                );
256
                return false;
257
            }
258
        } catch (InvalidAuthenticationTimeException $e) {
259
            $this->logger->notice('The SSO on 2FA cookie contained an invalid authentication time', [$e->getMessage()]);
260
            return false;
261
        }
262
        return true;
263
    }
264
}
265