simplesamlphp /
simplesamlphp-module-webauthn
| 1 | <?php |
||
| 2 | |||
| 3 | declare(strict_types=1); |
||
| 4 | |||
| 5 | namespace SimpleSAML\Module\webauthn\Controller; |
||
| 6 | |||
| 7 | use SimpleSAML\Auth; |
||
| 8 | use SimpleSAML\Configuration; |
||
| 9 | use SimpleSAML\Error; |
||
| 10 | use SimpleSAML\Logger; |
||
| 11 | use SimpleSAML\Module; |
||
| 12 | use SimpleSAML\Module\webauthn\Store; |
||
| 13 | use SimpleSAML\Module\webauthn\WebAuthn\StateData; |
||
| 14 | use SimpleSAML\Session; |
||
| 15 | use SimpleSAML\Utils; |
||
| 16 | use SimpleSAML\XHTML\Template; |
||
| 17 | use Symfony\Component\HttpFoundation\Request; |
||
| 18 | |||
| 19 | /** |
||
| 20 | * Controller class for the webauthn module. |
||
| 21 | * |
||
| 22 | * This class serves the different views available in the module. |
||
| 23 | * |
||
| 24 | * @package SimpleSAML\Module\webauthn |
||
| 25 | */ |
||
| 26 | class WebAuthn |
||
| 27 | { |
||
| 28 | public const STATE_AUTH_NOMGMT = 1; // just authenticate user |
||
| 29 | |||
| 30 | public const STATE_AUTH_ALLOWMGMT = 2; // allow to switch to mgmt page |
||
| 31 | |||
| 32 | public const STATE_MGMT = 4; // show token management page |
||
| 33 | |||
| 34 | |||
| 35 | /** @var \SimpleSAML\Auth\State|string */ |
||
| 36 | protected $authState = Auth\State::class; |
||
| 37 | |||
| 38 | /** @var \SimpleSAML\Logger|string */ |
||
| 39 | protected $logger = Logger::class; |
||
| 40 | |||
| 41 | |||
| 42 | /** |
||
| 43 | * Controller constructor. |
||
| 44 | * |
||
| 45 | * It initializes the global configuration and session for the controllers implemented here. |
||
| 46 | * |
||
| 47 | * @param \SimpleSAML\Configuration $config The configuration to use by the controllers. |
||
| 48 | * @param \SimpleSAML\Session $session The session to use by the controllers. |
||
| 49 | * |
||
| 50 | * @throws \Exception |
||
| 51 | */ |
||
| 52 | public function __construct( |
||
| 53 | protected Configuration $config, |
||
| 54 | protected Session $session, |
||
| 55 | ) { |
||
| 56 | } |
||
| 57 | |||
| 58 | |||
| 59 | /** |
||
| 60 | * Inject the \SimpleSAML\Auth\State dependency. |
||
| 61 | * |
||
| 62 | * @param \SimpleSAML\Auth\State $authState |
||
| 63 | */ |
||
| 64 | public function setAuthState(Auth\State $authState): void |
||
| 65 | { |
||
| 66 | $this->authState = $authState; |
||
| 67 | } |
||
| 68 | |||
| 69 | |||
| 70 | /** |
||
| 71 | * Inject the \SimpleSAML\Logger dependency. |
||
| 72 | * |
||
| 73 | * @param \SimpleSAML\Logger $logger |
||
| 74 | */ |
||
| 75 | public function setLogger(Logger $logger): void |
||
| 76 | { |
||
| 77 | $this->logger = $logger; |
||
| 78 | } |
||
| 79 | |||
| 80 | |||
| 81 | public static function workflowStateMachine(array $state) |
||
| 82 | { |
||
| 83 | // if we don't have any credentials yet, allow user to register |
||
| 84 | // regardless if in inflow or standalone (redirect to standalone if need |
||
| 85 | // be) |
||
| 86 | // OTOH, if we are invoked for passwordless auth, we don't know the |
||
| 87 | // username nor whether the user has any credentials. The only thing |
||
| 88 | // we can do is authenticate -> final else |
||
| 89 | if ( |
||
| 90 | $state['FIDO2PasswordlessAuthMode'] != true && |
||
| 91 | (!isset($state['FIDO2Tokens']) || count($state['FIDO2Tokens']) == 0) |
||
| 92 | ) { |
||
| 93 | return self::STATE_MGMT; |
||
| 94 | } |
||
| 95 | // from here on we do have a credential to work with |
||
| 96 | // |
||
| 97 | // user indicated he wants to manage tokens. He did so either by |
||
| 98 | // visiting the Registration page, or by checking the box during |
||
| 99 | // inflow. |
||
| 100 | // If coming from inflow, allow management only if user is |
||
| 101 | // properly authenticated, otherwise send to auth page |
||
| 102 | if ($state['FIDO2WantsRegister']) { |
||
| 103 | if ($state['FIDO2AuthSuccessful'] || $state['Registration']) { |
||
| 104 | return self::STATE_MGMT; |
||
| 105 | } |
||
| 106 | return self::STATE_AUTH_ALLOWMGMT; |
||
| 107 | } else { // in inflow, allow to check the management box; otherwise, |
||
| 108 | // only auth |
||
| 109 | $moduleConfig = Configuration::getOptionalConfig('module_webauthn.php')->toArray(); |
||
| 110 | return $moduleConfig['registration']['use_inflow_registration'] ? |
||
| 111 | self::STATE_AUTH_ALLOWMGMT : self::STATE_AUTH_NOMGMT; |
||
| 112 | } |
||
| 113 | } |
||
| 114 | |||
| 115 | |||
| 116 | public static function loadModuleConfig(array $moduleConfig, StateData &$stateData): void |
||
| 117 | { |
||
| 118 | $stateData->store = Store::parseStoreConfig($moduleConfig['store']); |
||
| 119 | |||
| 120 | // Set the optional scope if set by configuration |
||
| 121 | if (array_key_exists('scope', $moduleConfig)) { |
||
| 122 | $stateData->scope = $moduleConfig['scope']; |
||
| 123 | } |
||
| 124 | |||
| 125 | // Set the derived scope so we can compare it to the sent host at a later point |
||
| 126 | $httpUtils = new Utils\HTTP(); |
||
| 127 | $baseurl = $httpUtils->getSelfHost(); |
||
| 128 | $hostname = parse_url($baseurl, PHP_URL_HOST); |
||
| 129 | if ($hostname !== null) { |
||
| 130 | $stateData->derivedScope = $hostname; |
||
| 131 | } |
||
| 132 | |||
| 133 | if (array_key_exists('identifyingAttribute', $moduleConfig)) { |
||
| 134 | $stateData->usernameAttrib = $moduleConfig['identifyingAttribute']; |
||
| 135 | } else { |
||
| 136 | throw new Error\CriticalConfigurationError( |
||
| 137 | 'webauthn: it is required to set identifyingAttribute in config.', |
||
| 138 | ); |
||
| 139 | } |
||
| 140 | |||
| 141 | if (array_key_exists('attrib_displayname', $moduleConfig)) { |
||
| 142 | $stateData->displaynameAttrib = $moduleConfig['attrib_displayname']; |
||
| 143 | } else { |
||
| 144 | throw new Error\CriticalConfigurationError( |
||
| 145 | 'webauthn: it is required to set attrib_displayname in config.', |
||
| 146 | ); |
||
| 147 | } |
||
| 148 | |||
| 149 | if (array_key_exists('minimum_certification_level', $moduleConfig['registration']['policy_2fa'])) { |
||
| 150 | // phpcs:disable Generic.Files.LineLength.TooLong |
||
| 151 | $stateData->requestTokenModel = ($moduleConfig['registration']['policy_2fa']['minimum_certification_level'] == Module\webauthn\WebAuthn\WebAuthnRegistrationEvent::CERTIFICATION_NOT_REQUIRED ? false : true); |
||
| 152 | $stateData->minCertLevel2FA = $moduleConfig['registration']['policy_2fa']['minimum_certification_level']; |
||
| 153 | $stateData->aaguidWhitelist2FA = $moduleConfig['registration']['policy_2fa']['aaguid_whitelist'] ?? []; |
||
| 154 | $stateData->attFmtWhitelist2FA = $moduleConfig['registration']['policy_2fa']['attestation_format_whitelist'] ?? []; |
||
| 155 | $stateData->minCertLevelPasswordless = $moduleConfig['registration']['policy_passwordless']['minimum_certification_level']; |
||
| 156 | $stateData->aaguidWhitelistPasswordless = $moduleConfig['registration']['policy_passwordless']['aaguid_whitelist'] ?? []; |
||
| 157 | $stateData->attFmtWhitelistPasswordless = $moduleConfig['registration']['policy_passwordless']['attestation_format_whitelist'] ?? []; |
||
| 158 | // phpcs:enable Generic.Files.LineLength.TooLong |
||
| 159 | } else { |
||
| 160 | $stateData->requestTokenModel = false; |
||
| 161 | } |
||
| 162 | } |
||
| 163 | |||
| 164 | |||
| 165 | /** |
||
| 166 | * @param \Symfony\Component\HttpFoundation\Request $request |
||
| 167 | * @return \SimpleSAML\XHTML\Template A Symfony Response-object. |
||
| 168 | */ |
||
| 169 | public function main(Request $request): Template |
||
| 170 | { |
||
| 171 | $this->logger::info('FIDO2 - Accessing WebAuthn interface'); |
||
| 172 | |||
| 173 | $stateId = $request->query->get('StateId'); |
||
| 174 | if ($stateId === null) { |
||
| 175 | throw new Error\BadRequest('Missing required StateId query parameter.'); |
||
| 176 | } |
||
| 177 | |||
| 178 | $state = $this->authState::loadState($stateId, 'webauthn:request'); |
||
| 179 | |||
| 180 | if ($this->workflowStateMachine($state) != self::STATE_AUTH_NOMGMT) { |
||
|
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||
| 181 | $templateFile = 'webauthn:webauthn.twig'; |
||
| 182 | } else { |
||
| 183 | $templateFile = 'webauthn:authentication.twig'; |
||
| 184 | } |
||
| 185 | |||
| 186 | // Make, populate and layout consent form |
||
| 187 | $t = new Template($this->config, $templateFile); |
||
| 188 | $t->data['UserID'] = $state['FIDO2Username']; |
||
| 189 | $t->data['FIDO2Tokens'] = $state['FIDO2Tokens']; |
||
| 190 | // in case IdPs want to override UI and display SP-specific content |
||
| 191 | $t->data['entityid'] = $state['SPMetadata']['entityid'] ?? 'WEBAUTHN-SP-NONE'; |
||
| 192 | |||
| 193 | $challenge = str_split($state['FIDO2SignupChallenge'], 2); |
||
| 194 | $configUtils = new Utils\Config(); |
||
| 195 | $username = str_split( |
||
| 196 | hash('sha512', $state['FIDO2Username'] . '|' . $configUtils->getSecretSalt()), |
||
| 197 | 2, |
||
| 198 | ); |
||
| 199 | |||
| 200 | $challengeEncoded = []; |
||
| 201 | foreach ($challenge as $oneChar) { |
||
| 202 | $challengeEncoded[] = hexdec($oneChar); |
||
| 203 | } |
||
| 204 | |||
| 205 | $credentialIdEncoded = []; |
||
| 206 | foreach ($state['FIDO2Tokens'] as $number => $token) { |
||
| 207 | $idSplit = str_split($token[0], 2); |
||
| 208 | $credentialIdEncoded[$number] = []; |
||
| 209 | foreach ($idSplit as $credIdBlock) { |
||
| 210 | $credentialIdEncoded[$number][] = hexdec($credIdBlock); |
||
| 211 | } |
||
| 212 | } |
||
| 213 | |||
| 214 | $usernameEncoded = []; |
||
| 215 | foreach ($username as $oneChar) { |
||
| 216 | $usernameEncoded[] = hexdec($oneChar); |
||
| 217 | } |
||
| 218 | |||
| 219 | $frontendData = []; |
||
| 220 | $frontendData['challengeEncoded'] = $challengeEncoded; |
||
| 221 | $frontendData['state'] = []; |
||
| 222 | foreach (['FIDO2Scope','FIDO2Username','FIDO2Displayname','requestTokenModel'] as $stateItem) { |
||
| 223 | $frontendData['state'][$stateItem] = $state[$stateItem]; |
||
| 224 | } |
||
| 225 | |||
| 226 | $t->data['showExitButton'] = !array_key_exists('Registration', $state); |
||
| 227 | $frontendData['usernameEncoded'] = $usernameEncoded; |
||
| 228 | $frontendData['attestation'] = $state['requestTokenModel'] ? "indirect" : "none"; |
||
| 229 | $frontendData['credentialIdEncoded'] = $credentialIdEncoded; |
||
| 230 | $frontendData['FIDO2PasswordlessAuthMode'] = $state['FIDO2PasswordlessAuthMode']; |
||
| 231 | $t->data['frontendData'] = json_encode($frontendData); |
||
| 232 | |||
| 233 | $t->data['FIDO2AuthSuccessful'] = $state['FIDO2AuthSuccessful']; |
||
| 234 | if ($this->workflowStateMachine($state) == self::STATE_MGMT) { |
||
| 235 | $t->data['regURL'] = Module::getModuleURL('webauthn/regprocess?StateId=' . urlencode($stateId)); |
||
| 236 | $t->data['delURL'] = Module::getModuleURL('webauthn/managetoken?StateId=' . urlencode($stateId)); |
||
| 237 | } |
||
| 238 | |||
| 239 | $t->data['authForm'] = ""; |
||
| 240 | if ( |
||
| 241 | $this->workflowStateMachine($state) == self::STATE_AUTH_ALLOWMGMT || |
||
| 242 | $this->workflowStateMachine($state) == self::STATE_AUTH_NOMGMT |
||
| 243 | ) { |
||
| 244 | $t->data['authURL'] = Module::getModuleURL('webauthn/authprocess?StateId=' . urlencode($stateId)); |
||
| 245 | $t->data['delURL'] = Module::getModuleURL('webauthn/managetoken?StateId=' . urlencode($stateId)); |
||
| 246 | } |
||
| 247 | |||
| 248 | // dynamically generate the JS code needed for token registration |
||
| 249 | return $t; |
||
| 250 | } |
||
| 251 | } |
||
| 252 |