CookieService::isCookieValid()   B
last analyzed

Complexity

Conditions 8
Paths 7

Size

Total Lines 38
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 26
nc 7
nop 3
dl 0
loc 38
rs 8.4444
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
 */
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
/**
41
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects) | Coupling is high as we are integrating logic into the infrastructure
42
 */
43
class CookieService implements CookieServiceInterface
44
{
45
    /**
46
     * @var CookieHelperInterface
47
     */
48
    private $cookieHelper;
49
    /**
50
     * @var InstitutionConfigurationService
51
     */
52
    private $institutionConfigurationService;
53
    /**
54
     * @var LoggerInterface
55
     */
56
    private $logger;
57
    /**
58
     * @var SecondFactorService
59
     */
60
    private $secondFactorService;
61
62
    /**
63
     * @var SecondFactorTypeService
64
     */
65
    private $secondFactorTypeService;
66
    /**
67
     * @var ExpirationHelperInterface
68
     */
69
    private $expirationHelper;
70
71
    public function __construct(
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(
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->getInstitution());
115
            $this->logger->notice(
116
                sprintf(
117
                    'SSO on 2FA is %senabled for %s',
118
                    $isEnabled ? '' : 'not ',
119
                    $secondFactor->getInstitution()
120
                )
121
            );
122
123
            if ($isEnabled) {
124
                $identityId = $responseContext->getIdentityNameId();
125
                $loa = $this->secondFactorService->getLoaLevel($secondFactor);
126
                $isVerifiedBySsoOn2faCookie = $responseContext->isVerifiedBySsoOn2faCookie();
127
                // Did the user perform a new second factor authentication?
128
                if (!$isVerifiedBySsoOn2faCookie) {
129
                    $cookie = CookieValue::from($identityId, $secondFactor->getSecondFactorId(), $loa->getLevel());
130
                    $this->store($httpResponse, $cookie);
131
                }
132
            }
133
        }
134
        $responseContext->finalizeAuthentication();
135
        return $httpResponse;
136
    }
137
138
    /**
139
     * Test if the conditions of this authentication allows a SSO on 2FA
140
     */
141
    public function maySkipAuthentication(
142
        float $requiredLoa,
143
        string $identityNameId,
144
        CookieValueInterface $ssoCookie
145
    ): bool {
146
147
        // Perform validation on the cookie and its contents
148
        if (!$this->isCookieValid($ssoCookie, $requiredLoa, $identityNameId)) {
149
            return false;
150
        }
151
152
        if (!$this->secondFactorService->findByUuid($ssoCookie->secondFactorId())) {
153
            $this->logger->notice(
154
                'The second factor stored in the SSO cookie was revoked or has otherwise became unknown to Gateway',
155
                [
156
                    'secondFactorIdFromCookie' => $ssoCookie->secondFactorId()
157
                ]
158
            );
159
            return false;
160
        }
161
162
        $this->logger->notice('Verified the current 2FA authentication can be given with the SSO on 2FA cookie');
163
        return true;
164
    }
165
166
    public function preconditionsAreMet(ResponseContext $responseContext): bool
167
    {
168
        if ($responseContext->isForceAuthn()) {
169
            $this->logger->notice('Ignoring SSO on 2FA cookie when ForceAuthN is specified.');
170
            return false;
171
        }
172
        $remoteSp = $this->getRemoteSp($responseContext);
173
        // Test if the SP allows SSO on 2FA to take place (configured in MW config)
174
        if (!$remoteSp->allowSsoOn2fa()) {
175
            $this->logger->notice(
176
                sprintf(
177
                    'SSO on 2FA is disabled by config for SP: %s',
178
                    $remoteSp->getEntityId()
179
                )
180
            );
181
            return false;
182
        }
183
        return true;
184
    }
185
186
    public function getCookieFingerprint(Request $request): string
187
    {
188
        return $this->cookieHelper->fingerprint($request);
189
    }
190
191
    private function store(Response $response, CookieValueInterface $cookieValue): void
192
    {
193
        $this->cookieHelper->write($response, $cookieValue);
194
    }
195
196
    public function read(Request $request): CookieValueInterface
197
    {
198
        try {
199
            return $this->cookieHelper->read($request);
200
        } catch (CookieNotFoundException $e) {
201
            $this->logger->notice('The SSO on 2FA cookie is not found in the request header');
202
            return new NullCookieValue();
203
        } catch (DecryptionFailedException $e) {
204
            $this->logger->notice('Decryption of the SSO on 2FA cookie failed');
205
            return new NullCookieValue();
206
        } catch (Exception $e) {
207
            $this->logger->notice(
208
                'Decryption failed, see original message in context',
209
                ['original-exception-message' => $e->getMessage()]
210
            );
211
            return new NullCookieValue();
212
        }
213
    }
214
215
    private function getRemoteSp(ResponseContext $responseContext): ServiceProvider
216
    {
217
        $remoteSp = $responseContext->getServiceProvider();
218
        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...
219
            throw new RuntimeException('SP not found in the response context, unable to continue with SSO on 2FA');
220
        }
221
        return $remoteSp;
222
    }
223
224
    private function isCookieValid(CookieValueInterface $ssoCookie, float $requiredLoa, string $identityNameId): bool
225
    {
226
        if ($ssoCookie instanceof NullCookieValue) {
227
            return false;
228
        }
229
        if ($ssoCookie instanceof CookieValue && !$ssoCookie->meetsRequiredLoa($requiredLoa)) {
230
            $this->logger->notice(
231
                sprintf(
232
                    'The required LoA %d did not match the LoA of the SSO cookie LoA %d',
233
                    $requiredLoa,
234
                    $ssoCookie->getLoa()
235
                )
236
            );
237
            return false;
238
        }
239
        if ($ssoCookie instanceof CookieValue && !$ssoCookie->issuedTo($identityNameId)) {
240
            $this->logger->notice(
241
                sprintf(
242
                    'The SSO on 2FA cookie was not issued to %s, but to %s',
243
                    $identityNameId,
244
                    $ssoCookie->getIdentityId()
245
                )
246
            );
247
            return false;
248
        }
249
        try {
250
            $isExpired = $this->expirationHelper->isExpired($ssoCookie);
251
            if ($isExpired) {
252
                $this->logger->notice(
253
                    'The SSO on 2FA cookie has expired. Meaning [authentication time] + [cookie lifetime] is in the past'
254
                );
255
                return false;
256
            }
257
        } catch (InvalidAuthenticationTimeException $e) {
258
            $this->logger->notice('The SSO on 2FA cookie contained an invalid authentication time', [$e->getMessage()]);
259
            return false;
260
        }
261
        return true;
262
    }
263
}
264