AuthProcess::main()   F
last analyzed

Complexity

Conditions 27
Paths 632

Size

Total Lines 212
Code Lines 101

Duplication

Lines 0
Ratio 0 %

Importance

Changes 7
Bugs 1 Features 1
Metric Value
cc 27
eloc 101
c 7
b 1
f 1
nc 632
nop 1
dl 0
loc 212
rs 0.4088

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\webauthn\Controller;
6
7
use DateTime;
8
use Exception;
9
use SimpleSAML\Auth;
10
use SimpleSAML\Auth\Source;
11
use SimpleSAML\Configuration;
12
use SimpleSAML\Error;
13
use SimpleSAML\HTTP\RunnableResponse;
14
use SimpleSAML\Logger;
15
use SimpleSAML\Module;
16
use SimpleSAML\Module\webauthn\WebAuthn\WebAuthnAbstractEvent;
17
use SimpleSAML\Module\webauthn\WebAuthn\WebAuthnAuthenticationEvent;
18
use SimpleSAML\Session;
19
use Symfony\Component\HttpFoundation\RedirectResponse;
20
use Symfony\Component\HttpFoundation\Request;
21
use Symfony\Component\HttpFoundation\Response;
22
23
/**
24
 * Controller class for the webauthn module.
25
 *
26
 * This class serves the different views available in the module.
27
 *
28
 * @package SimpleSAML\Module\webauthn
29
 */
30
class AuthProcess
31
{
32
    /** @var \SimpleSAML\Auth\State|string */
33
    protected $authState = Auth\State::class;
34
35
    /** @var \SimpleSAML\Logger|string */
36
    protected $logger = Logger::class;
37
38
39
    /**
40
     * Controller constructor.
41
     *
42
     * It initializes the global configuration and session for the controllers implemented here.
43
     *
44
     * @param \SimpleSAML\Configuration              $config The configuration to use by the controllers.
45
     * @param \SimpleSAML\Session                    $session The session to use by the controllers.
46
     *
47
     * @throws \Exception
48
     */
49
    public function __construct(
50
        protected Configuration $config,
51
        protected Session $session,
52
    ) {
53
    }
54
55
56
    /**
57
     * Inject the \SimpleSAML\Auth\State dependency.
58
     *
59
     * @param \SimpleSAML\Auth\State $authState
60
     */
61
    public function setAuthState(Auth\State $authState): void
62
    {
63
        $this->authState = $authState;
64
    }
65
66
67
    /**
68
     * Inject the \SimpleSAML\Logger dependency.
69
     *
70
     * @param \SimpleSAML\Logger $logger
71
     */
72
    public function setLogger(Logger $logger): void
73
    {
74
        $this->logger = $logger;
75
    }
76
77
78
    /**
79
     * @param \Symfony\Component\HttpFoundation\Request $request
80
     * @return (
0 ignored issues
show
Documentation Bug introduced by
The doc comment ( at position 1 could not be parsed: the token is null at position 1.
Loading history...
81
     *   \Symfony\Component\HttpFoundation\RedirectResponse|
82
     *   \SimpleSAML\HTTP\RunnableResponse
83
     * ) A Symfony Response-object.
84
     */
85
    public function main(Request $request): Response
86
    {
87
        $this->logger::info('FIDO2 - Accessing WebAuthn enrollment validation');
88
89
        $stateId = $request->query->get('StateId');
90
        if ($stateId === null) {
91
            throw new Error\BadRequest('Missing required StateId query parameter.');
92
        }
93
94
        $moduleConfig = Configuration::getOptionalConfig('module_webauthn.php');
95
        $debugEnabled = $moduleConfig->getOptionalBoolean('debug', false);
96
97
        $state = $this->authState::loadState($stateId, 'webauthn:request');
98
99
        $incomingID = bin2hex(WebAuthnAbstractEvent::base64urlDecode($request->request->get('response_id')));
100
101
        /**
102
         * For passwordless auth, extract the userid from the response of the
103
         * discoverable credential, look up whether the credential used is one
104
         * that belongs to the claimed username
105
         *
106
         * Fail auth if not found, otherwise treat this auth like any other
107
         * (but check later whether UV was set during auth for the token at hand)
108
         */
109
        if ($state['FIDO2PasswordlessAuthMode'] === true) {
110
            $usernameBuffer = "";
111
            foreach (str_split(base64_decode($request->request->get('userHandle'))) as $oneChar) {
112
                $usernameBuffer .= bin2hex($oneChar);
113
            }
114
            $store = $state['webauthn:store'];
115
            $userForToken = $store->getUsernameByHashedId($usernameBuffer);
116
            if ($userForToken !== "") {
117
                $tokensForUser = $store->getTokenData($userForToken);
118
                $state['FIDO2Username'] = $userForToken;
119
                $state['FIDO2Tokens'] = $tokensForUser;
120
            } else {
121
                throw new Exception("Credential ID cannot be associated to any user!");
122
            }
123
        }
124
125
        /**
126
         * §7.2 STEP 2 - 4 : check that the credential is one of those the particular user owns
127
         */
128
        $publicKey = false;
129
        $previousCounter = -1;
130
        $oneToken = [];
131
132
        foreach ($state['FIDO2Tokens'] as $oneToken) {
133
            if ($oneToken[0] == $incomingID) {
134
                // Credential ID is eligible for user $state['FIDO2Username'];
135
                // using publicKey $oneToken[1] with current counter value $oneToken[2]
136
                $publicKey = $oneToken[1];
137
                $previousCounter = $oneToken[2];
138
                break;
139
            }
140
        }
141
142
        if ($publicKey === false || sizeof($oneToken) === 0) {
143
            throw new Exception(
144
            // phpcs:ignore Generic.Files.LineLength.TooLong
145
                "User attempted to authenticate with an unknown credential ID. This should already have been prevented by the browser!",
146
            );
147
        }
148
149
        if (!is_string($oneToken[1])) {
150
            $oneToken[1] = stream_get_contents($oneToken[1]);
151
        }
152
153
        $authObject = new WebAuthnAuthenticationEvent(
154
            $request->request->get('type'),
155
            ($state['FIDO2Scope'] === null ? $state['FIDO2DerivedScope'] : $state['FIDO2Scope']),
156
            $state['FIDO2SignupChallenge'],
157
            base64_decode($request->request->get('authenticator_data')),
158
            base64_decode($request->request->get('client_data_raw')),
159
            $oneToken[0],
160
            $oneToken[1],
161
            (int)$oneToken[4], // algo
162
            base64_decode($request->request->get('signature')),
163
            $debugEnabled,
0 ignored issues
show
Bug introduced by
It seems like $debugEnabled can also be of type null; however, parameter $debugMode of SimpleSAML\Module\webaut...ionEvent::__construct() does only seem to accept boolean, 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

163
            /** @scrutinizer ignore-type */ $debugEnabled,
Loading history...
164
        );
165
166
        /** Custom check: if the token was initially registered with UV, but now
167
         * authenticates only UP, we don't allow this downgrade.
168
         *
169
         * This is not typically allowed by authenticator implementations anyway
170
         * (they typically require a full reset of the key to remove UV
171
         * protections) but to be safe: find out and tell user to re-enroll with
172
         * the lower security level. (level upgrades are of course OK.)
173
         */
174
        if ($oneToken[5] > $authObject->getPresenceLevel()) {
175
            // phpcs:ignore Generic.Files.LineLength.TooLong
176
            throw new Exception("Token was initially registered with higher identification guarantees than now authenticated with (was: " . $oneToken[5] . " now " . $authObject->getPresenceLevel() . "!");
177
        }
178
179
        // no matter what: if we are passwordless it MUST be presence-verified
180
        if (
181
            $state['FIDO2PasswordlessAuthMode'] === true &&
182
            $oneToken[5] !== WebAuthnAbstractEvent::PRESENCE_LEVEL_VERIFIED
183
        ) {
184
            throw new Exception("Attempt to authenticate without User Verification in passwordless mode!");
185
        }
186
187
        // if we didn't register the key as resident, do not allow its use in
188
        // passwordless mode
189
        if ($state['FIDO2PasswordlessAuthMode'] === true && $oneToken[6] !== 1) {
190
            throw new Exception("Attempt to authenticate with a token that is not registered for passwordless mode!");
191
        }
192
193
        /**
194
         * §7.2 STEP 18 : detect physical object cloning on the token
195
         */
196
        $counter = $authObject->getCounter();
197
        if ($previousCounter === 0 && $counter === 0) {
198
            // no cloning check, it is a brand new token
199
        } elseif ($counter > $previousCounter) {
200
            // Signature counter was incremented compared to last time, good
201
            $store = $state['webauthn:store'];
202
            $store->updateSignCount($oneToken[0], $counter);
203
        } else {
204
            throw new Exception(
205
            // phpcs:ignore Generic.Files.LineLength.TooLong
206
                "Signature counter less or equal to a previous authentication! Token cloning likely (old: $previousCounter, new: $counter).",
207
            );
208
        }
209
210
        // THAT'S IT. The user authenticated successfully. Remember the credential ID that was used.
211
        $state['FIDO2AuthSuccessful'] = $oneToken[0];
212
213
        // See if he wants to hang around for token management operations
214
        if ($request->request->get('credentialChange') === 'on') {
215
            $state['FIDO2WantsRegister'] = true;
216
        } else {
217
            $state['FIDO2WantsRegister'] = false;
218
        }
219
220
        $this->authState::saveState($state, 'webauthn:request');
0 ignored issues
show
Bug introduced by
It seems like $state can also be of type null; however, parameter $state of SimpleSAML\Auth\State::saveState() does only seem to accept array, 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

220
        $this->authState::saveState(/** @scrutinizer ignore-type */ $state, 'webauthn:request');
Loading history...
221
222
        if ($debugEnabled) {
223
            $response = new RunnableResponse(
224
                function (WebAuthnAuthenticationEvent $authObject, array $state) {
225
                    echo $authObject->getDebugBuffer();
226
                    echo $authObject->getValidateBuffer();
227
                    echo "Debug mode, not continuing to " . ($state['FIDO2WantsRegister'] ?
228
                            "credential registration page." : "destination.");
229
                },
230
                [$authObject, $state],
231
            );
232
        } else {
233
            if ($state['FIDO2WantsRegister']) {
234
                $response = new RedirectResponse(
235
                    Module::getModuleURL('webauthn/webauthn?StateId=' . urlencode($stateId)),
236
                );
237
            } else {
238
                $response = new RunnableResponse([Auth\ProcessingChain::class, 'resumeProcessing'], [$state]);
239
            }
240
        }
241
        if ($state['FIDO2PasswordlessAuthMode'] === false) {
242
            // take note of the current timestamp so we know
243
            // a) that second-factor was done successfully in the current sesssion
244
            // b) when that event occured, so as to make regular re-auths configurable
245
            $this->session->setData("DateTime", 'LastSuccessfulSecondFactor', new \DateTime());
246
            $this->authState::saveState($state, 'webauthn:request');
247
        }
248
        if ($state['FIDO2PasswordlessAuthMode'] === true) {
249
            /**
250
             * But what about SAML attributes? As an authproc, those came in by the
251
             * first-factor authentication.
252
             * In passwordless, we're on our own. The one thing we know is the
253
             * username.
254
             */
255
            $state['Attributes'][$state['FIDO2AttributeStoringUsername']] = [ $state['FIDO2Username'] ];
256
            // in case this authentication happened in the Supercharged context
257
            // it may be that there is an authprocfilter for WebAuthN, too.
258
259
            // If so, remove it from $state as it is stupid to touch the token
260
            // twice; once in the Passwordless auth source and once as an
261
            // authprocfilter
262
263
            // this didn't actually work; authprocfilter self-removes instead
264
            // if it found Passwordless to be successful in the same session
265
266
            foreach ($state['IdPMetadata']['authproc'] as $index => $content) {
267
                if ($content['class'] == "webauthn:WebAuthn") {
268
                    unset($state['IdPMetadata']['authproc'][$index]);
269
                }
270
            }
271
            // set an internal "authenticated passwordless" hint somewhere else
272
            // in $state, which the authproc can react upon
273
            $state['Attributes']['internal:FIDO2PasswordlessAuthentication'] = [ $state['FIDO2Username'] ];
274
275
            $this->authState::saveState($state, 'webauthn:request');
276
277
            // set a cookie to remember that the user has successfully used
278
            // Passwordless - on the Supercharged AuthSource, this can be used
279
            // to auto-trigger the FIDO2 authentication step next time
280
            setcookie("SuccessfullyUsedPasswordlessBefore", "YES", time() + (3600 * 24 * 90), '/', "", true, true);
281
282
            // now properly return our final state to the framework
283
            Source::completeAuth($state);
284
        }
285
286
        $response->setExpires(new DateTime('Thu, 19 Nov 1981 08:52:00 GMT'));
287
        $response->setCache([
288
            'must_revalidate'  => true,
289
            'no_cache'         => true,
290
            'no_store'         => true,
291
            'no_transform'     => false,
292
            'public'           => false,
293
            'private'          => false,
294
        ]);
295
296
        return $response;
297
    }
298
}
299