simplesamlphp /
simplesamlphp-module-webauthn
| 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
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
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
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
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
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 |