AuthProcess::setAuthState()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 3
rs 10
nc 1
nop 1
cc 1
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
     * Controller constructor.
40
     *
41
     * It initializes the global configuration and session for the controllers implemented here.
42
     *
43
     * @param \SimpleSAML\Configuration              $config The configuration to use by the controllers.
44
     * @param \SimpleSAML\Session                    $session The session to use by the controllers.
45
     *
46
     * @throws \Exception
47
     */
48
    public function __construct(
49
        protected Configuration $config,
50
        protected Session $session,
51
    ) {
52
    }
53
54
    /**
55
     * Inject the \SimpleSAML\Auth\State dependency.
56
     *
57
     * @param \SimpleSAML\Auth\State $authState
58
     */
59
    public function setAuthState(Auth\State $authState): void
60
    {
61
        $this->authState = $authState;
62
    }
63
64
    /**
65
     * Inject the \SimpleSAML\Logger dependency.
66
     *
67
     * @param \SimpleSAML\Logger $logger
68
     */
69
    public function setLogger(Logger $logger): void
70
    {
71
        $this->logger = $logger;
72
    }
73
74
    /**
75
     * @param \Symfony\Component\HttpFoundation\Request $request
76
     * @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...
77
     *   \Symfony\Component\HttpFoundation\RedirectResponse|
78
     *   \SimpleSAML\HTTP\RunnableResponse
79
     * ) A Symfony Response-object.
80
     */
81
    public function main(Request $request): Response
82
    {
83
        $this->logger::info('FIDO2 - Accessing WebAuthn enrollment validation');
84
85
        $stateId = $request->query->get('StateId');
86
        if ($stateId === null) {
87
            throw new Error\BadRequest('Missing required StateId query parameter.');
88
        }
89
90
        $moduleConfig = Configuration::getOptionalConfig('module_webauthn.php');
91
        $debugEnabled = $moduleConfig->getOptionalBoolean('debug', false);
92
93
        $state = $this->authState::loadState($stateId, 'webauthn:request');
94
95
        $incomingID = bin2hex(WebAuthnAbstractEvent::base64urlDecode($request->request->get('response_id')));
96
97
        /**
98
         * For passwordless auth, extract the userid from the response of the
99
         * discoverable credential, look up whether the credential used is one
100
         * that belongs to the claimed username
101
         *
102
         * Fail auth if not found, otherwise treat this auth like any other
103
         * (but check later whether UV was set during auth for the token at hand)
104
         */
105
        if ($state['FIDO2PasswordlessAuthMode'] === true) {
106
            $usernameBuffer = "";
107
            foreach (str_split(base64_decode($request->request->get('userHandle'))) as $oneChar) {
108
                $usernameBuffer .= bin2hex($oneChar);
109
            }
110
            $store = $state['webauthn:store'];
111
            $userForToken = $store->getUsernameByHashedId($usernameBuffer);
112
            if ($userForToken !== "") {
113
                $tokensForUser = $store->getTokenData($userForToken);
114
                $state['FIDO2Username'] = $userForToken;
115
                $state['FIDO2Tokens'] = $tokensForUser;
116
            } else {
117
                throw new Exception("Credential ID cannot be associated to any user!");
118
            }
119
        }
120
121
        /**
122
         * §7.2 STEP 2 - 4 : check that the credential is one of those the particular user owns
123
         */
124
        $publicKey = false;
125
        $previousCounter = -1;
126
        $oneToken = [];
127
128
        foreach ($state['FIDO2Tokens'] as $oneToken) {
129
            if ($oneToken[0] == $incomingID) {
130
                // Credential ID is eligible for user $state['FIDO2Username'];
131
                // using publicKey $oneToken[1] with current counter value $oneToken[2]
132
                $publicKey = $oneToken[1];
133
                $previousCounter = $oneToken[2];
134
                break;
135
            }
136
        }
137
138
        if ($publicKey === false || sizeof($oneToken) === 0) {
139
            throw new Exception(
140
            // phpcs:ignore Generic.Files.LineLength.TooLong
141
                "User attempted to authenticate with an unknown credential ID. This should already have been prevented by the browser!",
142
            );
143
        }
144
145
        if (!is_string($oneToken[1])) {
146
            $oneToken[1] = stream_get_contents($oneToken[1]);
147
        }
148
149
        $authObject = new WebAuthnAuthenticationEvent(
150
            $request->request->get('type'),
151
            ($state['FIDO2Scope'] === null ? $state['FIDO2DerivedScope'] : $state['FIDO2Scope']),
152
            $state['FIDO2SignupChallenge'],
153
            base64_decode($request->request->get('authenticator_data')),
154
            base64_decode($request->request->get('client_data_raw')),
155
            $oneToken[0],
156
            $oneToken[1],
157
            (int)$oneToken[4], // algo
158
            base64_decode($request->request->get('signature')),
159
            $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

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

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