| Conditions | 27 |
| Paths | 632 |
| Total Lines | 212 |
| Code Lines | 101 |
| Lines | 0 |
| Ratio | 0 % |
| Changes | 7 | ||
| Bugs | 1 | Features | 1 |
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:
If many parameters/temporary variables are present:
| 1 | <?php |
||
| 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, |
||
| 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'); |
||
| 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 | } |
||
| 299 |