Passed
Push — bugfix/sso-cookie-writes-refin... ( 8c2baa )
by Michiel
13:49 queued 06:41
created

CookieService::handleSsoOn2faCookieStorage()   B

Complexity

Conditions 7
Paths 6

Size

Total Lines 49
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 7
eloc 27
c 3
b 0
f 0
nc 6
nop 3
dl 0
loc 49
rs 8.5546
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
    ): Response {
94
        // Check if this specific SP is configured to allow setting of an SSO on 2FA cookie (configured in MW config)
95
        $remoteSp = $this->getRemoteSp($responseContext);
96
        if (!$remoteSp->allowedToSetSsoCookieOn2fa()) {
97
            $this->logger->notice(
98
                sprintf(
99
                    'SP: %s does not allow writing SSO on 2FA cookies',
100
                    $remoteSp->getEntityId()
101
                )
102
            );
103
            return $httpResponse;
104
        }
105
106
        // The second factor id is read from state. It is the SF Id of the token used during authentication
107
        $secondFactorId = $responseContext->getSelectedSecondFactor();
108
109
        // We can only set an SSO on 2FA cookie if a second factor authentication is being handled.
110
        if ($secondFactorId) {
111
            $secondFactor = $this->secondFactorService->findByUuid($secondFactorId);
112
            if (!$secondFactor) {
113
                throw new RuntimeException(sprintf('Second Factor token not found with ID: %s', $secondFactorId));
114
            }
115
            // Test if the institution of the Identity this SF belongs to has SSO on 2FA enabled
116
            $isEnabled = $this->institutionConfigurationService->ssoOn2faEnabled($secondFactor->institution);
117
            $this->logger->notice(
118
                sprintf(
119
                    'SSO on 2FA is %senabled for %s',
120
                    $isEnabled ? '' : 'not ',
121
                    $secondFactor->institution
122
                )
123
            );
124
125
            if ($isEnabled) {
126
                $identityId = $responseContext->getIdentityNameId();
127
                $loa = $secondFactor->getLoaLevel($this->secondFactorTypeService);
128
                $isVerifiedBySsoOn2faCookie = $responseContext->isVerifiedBySsoOn2faCookie();
129
                // Did the user perform a new second factor authentication?
130
                if (!$isVerifiedBySsoOn2faCookie) {
131
                    $cookie = CookieValue::from($identityId, $secondFactor->secondFactorId, $loa);
132
                    $this->store($httpResponse, $cookie);
133
                }
134
            }
135
        }
136
        $responseContext->finalizeAuthentication();
137
        return $httpResponse;
138
    }
139
140
    /**
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...
141
     * Allow high cyclomatic complexity in favour of keeping this method readable
142
     * @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...
143
     * @SuppressWarnings(PHPMD.NPathComplexity)
144
     */
0 ignored issues
show
Coding Style introduced by
Missing @return tag in function comment
Loading history...
145
    public function shouldSkip2faAuthentication(
146
        ResponseContext $responseContext,
147
        float $requiredLoa,
148
        string $identityNameId,
149
        Collection $secondFactorCollection,
150
        Request $request
151
    ): bool {
152
        if ($responseContext->isForceAuthn()) {
153
            $this->logger->notice('Ignoring SSO on 2FA cookie when ForceAuthN is specified.');
154
            return false;
155
        }
156
        $remoteSp = $this->getRemoteSp($responseContext);
157
        // Test if the SP allows SSO on 2FA to take place (configured in MW config)
158
        if (!$remoteSp->allowSsoOn2fa()) {
159
            $this->logger->notice(
160
                sprintf(
161
                    'Ignoring SSO on 2FA for SP: %s',
162
                    $remoteSp->getEntityId()
163
                )
164
            );
165
            return false;
166
        }
167
        $ssoCookie = $this->read($request);
168
        // Perform validation on the cookie and its contents
169
        if (!$this->isCookieValid($ssoCookie, $requiredLoa, $identityNameId)) {
170
            return false;
171
        }
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
        /** @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...
183
        foreach ($secondFactorCollection as $secondFactor) {
184
            $loa = $secondFactor->getLoaLevel($this->secondFactorTypeService);
185
            if ($loa >= $requiredLoa) {
186
                $this->logger->notice('Verified the current 2FA authentication can be given with the SSO on 2FA cookie');
187
                $responseContext->saveSelectedSecondFactor($secondFactor);
188
                return true;
189
            }
190
        }
191
        return false;
192
    }
193
194
    public function getCookieFingerprint(Request $request): string
0 ignored issues
show
Coding Style introduced by
Missing doc comment for function getCookieFingerprint()
Loading history...
195
    {
196
        return $this->cookieHelper->fingerprint($request);
197
    }
198
199
    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...
200
    {
201
        $this->cookieHelper->write($response, $cookieValue);
202
    }
203
204
    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...
205
    {
206
        try {
207
            return $this->cookieHelper->read($request);
208
        } catch (CookieNotFoundException $e) {
209
            $this->logger->notice('Attempt to decrypt the cookie failed, the cookie could not be found');
210
            return new NullCookieValue();
211
        } catch (DecryptionFailedException $e) {
212
            $this->logger->notice('Decryption of the SSO on 2FA cookie failed');
213
            return new NullCookieValue();
214
        } catch (Exception $e) {
215
            $this->logger->notice(
216
                'Decryption failed, see original message in context',
217
                ['original-exception-message' => $e->getMessage()]
218
            );
219
            return new NullCookieValue();
220
        }
221
    }
222
223
    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...
224
    {
225
        $remoteSp = $responseContext->getServiceProvider();
226
        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...
227
            throw new RuntimeException('SP not found in the response context, unable to continue with SSO on 2FA');
228
        }
229
        return $remoteSp;
230
    }
231
232
    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...
233
    {
234
        if ($ssoCookie instanceof NullCookieValue) {
235
            return false;
236
        }
237
        if ($ssoCookie instanceof CookieValue && !$ssoCookie->meetsRequiredLoa($requiredLoa)) {
238
            $this->logger->notice(
239
                sprintf(
240
                    'The required LoA %d did not match the LoA of the SSO cookie %d',
241
                    $requiredLoa,
242
                    $ssoCookie->getLoa()
243
                )
244
            );
245
            return false;
246
        }
247
        if ($ssoCookie instanceof CookieValue && !$ssoCookie->issuedTo($identityNameId)) {
248
            $this->logger->notice(
249
                sprintf(
250
                    'The SSO on 2FA cookie was not issued to %s, but to %s',
251
                    $identityNameId,
252
                    $ssoCookie->getIdentityId()
253
                )
254
            );
255
            return false;
256
        }
257
        try {
258
            $isExpired = $this->expirationHelper->isExpired($ssoCookie);
259
            if ($isExpired) {
260
                $this->logger->notice(
261
                    'The SSO on 2FA cookie has expired. Meaning [authentication time] + [cookie lifetime] is in the past'
262
                );
263
                return false;
264
            }
265
        } catch (InvalidAuthenticationTimeException $e) {
266
            $this->logger->notice('The SSO on 2FA cookie contained an invalid authentication time', [$e->getMessage()]);
267
            return false;
268
        }
269
        return true;
270
    }
271
}
272