silverstripe /
silverstripe-saml
| 1 | <?php |
||
| 2 | |||
| 3 | namespace SilverStripe\SAML\Control; |
||
| 4 | |||
| 5 | use Exception; |
||
| 6 | use function gmmktime; |
||
| 7 | use OneLogin\Saml2\Auth; |
||
| 8 | use OneLogin\Saml2\Constants; |
||
| 9 | use OneLogin\Saml2\Utils; |
||
| 10 | use OneLogin\Saml2\Error; |
||
| 11 | use Psr\Log\LoggerInterface; |
||
| 12 | use SilverStripe\Core\Config\Config; |
||
| 13 | use SilverStripe\ORM\ValidationResult; |
||
| 14 | use SilverStripe\SAML\Authenticators\SAMLAuthenticator; |
||
| 15 | use SilverStripe\SAML\Authenticators\SAMLLoginForm; |
||
| 16 | use SilverStripe\SAML\Helpers\SAMLHelper; |
||
| 17 | use SilverStripe\Control\Controller; |
||
| 18 | use SilverStripe\Control\Director; |
||
| 19 | use SilverStripe\Control\HTTPResponse; |
||
| 20 | use SilverStripe\Core\Injector\Injector; |
||
| 21 | use SilverStripe\SAML\Model\SAMLResponse; |
||
| 22 | use SilverStripe\SAML\Services\SAMLConfiguration; |
||
| 23 | use SilverStripe\Security\IdentityStore; |
||
| 24 | use SilverStripe\Security\Member; |
||
| 25 | use SilverStripe\Security\Security; |
||
| 26 | use function uniqid; |
||
| 27 | |||
| 28 | /** |
||
| 29 | * Class SAMLController |
||
| 30 | * |
||
| 31 | * This controller handles serving metadata requests for the identity provider (IdP), as well as handling the creation |
||
| 32 | * of new users and logging them into SilverStripe after being authenticated at the IdP. |
||
| 33 | */ |
||
| 34 | class SAMLController extends Controller |
||
| 35 | { |
||
| 36 | /** |
||
| 37 | * @var array |
||
| 38 | */ |
||
| 39 | private static $allowed_actions = [ |
||
| 40 | 'index', |
||
| 41 | 'acs', |
||
| 42 | 'metadata' |
||
| 43 | ]; |
||
| 44 | |||
| 45 | public function index() |
||
| 46 | { |
||
| 47 | return $this->redirect('/'); |
||
| 48 | } |
||
| 49 | |||
| 50 | /** |
||
| 51 | * Assertion Consumer Service |
||
| 52 | * |
||
| 53 | * The user gets sent back here after authenticating with the IdP, off-site. |
||
| 54 | * The earlier redirection to the IdP can be found in the SAMLAuthenticator::authenticate. |
||
| 55 | * |
||
| 56 | * After this handler completes, we end up with a rudimentary Member record (which will be created on-the-fly |
||
| 57 | * if not existent), with the user already logged in. Login triggers memberLoggedIn hooks, which allows |
||
| 58 | * LDAP side of this module to finish off loading Member data. |
||
| 59 | * |
||
| 60 | * @throws Error |
||
| 61 | * @throws \Psr\Container\NotFoundExceptionInterface |
||
| 62 | */ |
||
| 63 | public function acs() |
||
| 64 | { |
||
| 65 | /** @var Auth $auth */ |
||
| 66 | $auth = Injector::inst()->get(SAMLHelper::class)->getSAMLAuth(); |
||
| 67 | $caughtException = null; |
||
| 68 | |||
| 69 | // Log both errors (reported by php-saml and thrown as exception) with a common ID for later tracking |
||
| 70 | $uniqueErrorId = uniqid('SAML-'); |
||
| 71 | |||
| 72 | // Force php-saml module to use the current absolute base URL (e.g. https://www.example.com/saml). This avoids |
||
| 73 | // errors that we otherwise get when having a multi-directory ACS URL like /saml/acs). |
||
| 74 | // See https://github.com/onelogin/php-saml/issues/249 |
||
| 75 | Utils::setBaseURL(Controller::join_links($auth->getSettings()->getSPData()['entityId'], 'saml')); |
||
| 76 | |||
| 77 | // Attempt to process the SAML response. If there are errors during this, log them and redirect to the generic |
||
| 78 | // error page. Note: This does not necessarily include all SAML errors (e.g. we still need to confirm if the |
||
| 79 | // user is authenticated after this block |
||
| 80 | try { |
||
| 81 | $auth->processResponse(); |
||
| 82 | $error = $auth->getLastErrorReason(); |
||
| 83 | } catch (Exception $e) { |
||
| 84 | $caughtException = $e; |
||
| 85 | } |
||
| 86 | |||
| 87 | // If there was an issue with the SAML response, if it was missing or if the SAML response indicates that they |
||
| 88 | // aren't authorised, then log the issue and provide a traceable error back to the user via the login form |
||
| 89 | $hasError = $caughtException || !empty($error); |
||
| 90 | if ($hasError || !$auth->isAuthenticated() || $this->checkForReplayAttack($auth, $uniqueErrorId)) { |
||
| 91 | if ($caughtException instanceof Exception) { |
||
| 92 | $this->getLogger()->error(sprintf( |
||
| 93 | '[%s] [code: %s] %s (%s:%s)', |
||
| 94 | $uniqueErrorId, |
||
| 95 | $e->getCode(), |
||
|
0 ignored issues
–
show
Comprehensibility
Best Practice
introduced
by
Loading history...
|
|||
| 96 | $e->getMessage(), |
||
| 97 | $e->getFile(), |
||
| 98 | $e->getLine() |
||
| 99 | )); |
||
| 100 | } |
||
| 101 | |||
| 102 | if (!empty($error)) { |
||
| 103 | $this->getLogger()->error(sprintf('[%s] %s', $uniqueErrorId, $error)); |
||
| 104 | } |
||
| 105 | |||
| 106 | $this->getForm()->sessionMessage( |
||
| 107 | _t( |
||
| 108 | 'SilverStripe\\SAML\\Control\\SAMLController.ERR_SAML_ACS_FAILURE', |
||
| 109 | 'Unfortunately we couldn\'t log you in. If this continues, please contact your I.T. department' |
||
| 110 | . ' with the following reference: {ref}', |
||
| 111 | ['ref' => $uniqueErrorId] |
||
| 112 | ), |
||
| 113 | ValidationResult::TYPE_ERROR |
||
| 114 | ); |
||
| 115 | |||
| 116 | // Redirect the user back to the login form to display the generic error message and reference |
||
| 117 | $this->getRequest()->getSession()->save($this->getRequest()); |
||
| 118 | return $this->redirect('Security/login'); |
||
| 119 | } |
||
| 120 | |||
| 121 | /** |
||
| 122 | * If processing reaches here, then the user is authenticated - the rest of this method is just processing their |
||
| 123 | * legitimate information and configuring their account. |
||
| 124 | */ |
||
| 125 | |||
| 126 | $helper = SAMLHelper::singleton(); |
||
| 127 | |||
| 128 | // If we expect the NameID to be a binary version of the GUID (ADFS), check that it actually is |
||
| 129 | // If we are configured not to expect a binary NameID, then we assume it is a direct GUID (Azure AD) |
||
| 130 | if (Config::inst()->get(SAMLConfiguration::class, 'expect_binary_nameid')) { |
||
| 131 | $decodedNameId = base64_decode($auth->getNameId()); |
||
| 132 | if (ctype_print($decodedNameId)) { |
||
| 133 | $this->getForm()->sessionMessage('NameID from IdP is not a binary GUID.', ValidationResult::TYPE_ERROR); |
||
| 134 | $this->getRequest()->getSession()->save($this->getRequest()); |
||
| 135 | return $this->getRedirect(); |
||
| 136 | } |
||
| 137 | |||
| 138 | // transform the NameId to guid |
||
| 139 | $guid = $helper->binToStrGuid($decodedNameId); |
||
| 140 | } else { |
||
| 141 | $guid = $auth->getNameId(); |
||
| 142 | } |
||
| 143 | |||
| 144 | if (!$helper->validGuid($guid)) { |
||
| 145 | $errorMessage = "Not a valid GUID '{$guid}' received from server."; |
||
| 146 | $this->getLogger()->error($errorMessage); |
||
| 147 | $this->getForm()->sessionMessage($errorMessage, ValidationResult::TYPE_ERROR); |
||
| 148 | $this->getRequest()->getSession()->save($this->getRequest()); |
||
| 149 | return $this->getRedirect(); |
||
| 150 | } |
||
| 151 | |||
| 152 | $this->extend('updateGuid', $guid); |
||
| 153 | |||
| 154 | $attributes = $auth->getAttributes(); |
||
| 155 | |||
| 156 | // Allows setups that map GUID (email format) to email {@see SAMLConfiguration::$expose_guid_as_attribute}. |
||
| 157 | if (Config::inst()->get(SAMLConfiguration::class, 'expose_guid_as_attribute')) { |
||
| 158 | $attributes['GUID'][0] = $guid; |
||
| 159 | } |
||
| 160 | |||
| 161 | $fieldToClaimMap = array_flip(Member::config()->claims_field_mappings); |
||
| 162 | |||
| 163 | // Write a rudimentary member with basic fields on every login, so that we at least have something |
||
| 164 | // if there is no further sync (e.g. via LDAP) |
||
| 165 | $member = Member::get()->filter('GUID', $guid)->limit(1)->first(); |
||
| 166 | if (!($member && $member->exists()) |
||
| 167 | && Config::inst()->get(SAMLConfiguration::class, 'allow_insecure_email_linking') |
||
| 168 | && isset($fieldToClaimMap['Email']) |
||
| 169 | ) { |
||
| 170 | // If there is no member found via GUID and we allow linking via email, search by email |
||
| 171 | $member = Member::get()->filter('Email', $attributes[$fieldToClaimMap['Email']])->limit(1)->first(); |
||
| 172 | |||
| 173 | if (!($member && $member->exists())) { |
||
| 174 | $member = new Member(); |
||
| 175 | } |
||
| 176 | |||
| 177 | $member->GUID = $guid; |
||
| 178 | } elseif (!($member && $member->exists())) { |
||
| 179 | // If the member doesn't exist and we don't allow linking via email, then create a new member |
||
| 180 | $member = new Member(); |
||
| 181 | $member->GUID = $guid; |
||
| 182 | } |
||
| 183 | |||
| 184 | foreach ($member->config()->claims_field_mappings as $claim => $field) { |
||
| 185 | if (!isset($attributes[$claim][0])) { |
||
| 186 | $this->getLogger()->warning( |
||
| 187 | sprintf( |
||
| 188 | 'Claim rule \'%s\' configured in SAMLMemberExtension.claims_field_mappings, ' . |
||
| 189 | 'but wasn\'t passed through. Please check IdP claim rules.', |
||
| 190 | $claim |
||
| 191 | ) |
||
| 192 | ); |
||
| 193 | |||
| 194 | continue; |
||
| 195 | } |
||
| 196 | |||
| 197 | $member->$field = $attributes[$claim][0]; |
||
| 198 | } |
||
| 199 | |||
| 200 | $member->SAMLSessionIndex = $auth->getSessionIndex(); |
||
| 201 | |||
| 202 | // This will trigger LDAP update through LDAPMemberExtension::memberLoggedIn, if the LDAP module is installed. |
||
| 203 | // The LDAP update will also write the Member record a second time, but the member *must* be written before |
||
| 204 | // IdentityStore->logIn() is called, otherwise the identity store throws an exception. |
||
| 205 | // Both SAML and LDAP identify Members by the same GUID field. |
||
| 206 | $member->write(); |
||
| 207 | |||
| 208 | /** @var IdentityStore $identityStore */ |
||
| 209 | $identityStore = Injector::inst()->get(IdentityStore::class); |
||
| 210 | $identityStore->logIn($member, false, $this->getRequest()); |
||
| 211 | |||
| 212 | return $this->getRedirect(); |
||
| 213 | } |
||
| 214 | |||
| 215 | /** |
||
| 216 | * Generate this SP's metadata. This is needed for intialising the SP-IdP relationship. |
||
| 217 | * IdP is instructed to call us back here to establish the relationship. IdP may also be configured |
||
| 218 | * to hit this endpoint periodically during normal operation, to check the SP availability. |
||
| 219 | */ |
||
| 220 | public function metadata() |
||
| 221 | { |
||
| 222 | try { |
||
| 223 | /** @var Auth $auth */ |
||
| 224 | $auth = Injector::inst()->get(SAMLHelper::class)->getSAMLAuth(); |
||
| 225 | $settings = $auth->getSettings(); |
||
| 226 | $metadata = $settings->getSPMetadata(); |
||
| 227 | $errors = $settings->validateMetadata($metadata); |
||
| 228 | if (empty($errors)) { |
||
| 229 | header('Content-Type: text/xml'); |
||
| 230 | echo $metadata; |
||
| 231 | } else { |
||
| 232 | throw new Error( |
||
| 233 | 'Invalid SP metadata: ' . implode(', ', $errors), |
||
| 234 | Error::METADATA_SP_INVALID |
||
| 235 | ); |
||
| 236 | } |
||
| 237 | } catch (Exception $e) { |
||
| 238 | $this->getLogger()->error($e->getMessage()); |
||
| 239 | echo $e->getMessage(); |
||
| 240 | } |
||
| 241 | } |
||
| 242 | |||
| 243 | /** |
||
| 244 | * @return HTTPResponse |
||
| 245 | */ |
||
| 246 | protected function getRedirect() |
||
| 247 | { |
||
| 248 | // Absolute redirection URLs may cause spoofing |
||
| 249 | if ($this->getRequest()->getSession()->get('BackURL') |
||
| 250 | && Director::is_site_url($this->getRequest()->getSession()->get('BackURL'))) { |
||
| 251 | return $this->redirect($this->getRequest()->getSession()->get('BackURL')); |
||
| 252 | } |
||
| 253 | |||
| 254 | // Spoofing attack, redirect to homepage instead of spoofing url |
||
| 255 | if ($this->getRequest()->getSession()->get('BackURL') |
||
| 256 | && !Director::is_site_url($this->getRequest()->getSession()->get('BackURL'))) { |
||
| 257 | return $this->redirect(Director::absoluteBaseURL()); |
||
| 258 | } |
||
| 259 | |||
| 260 | // If a default login dest has been set, redirect to that. |
||
| 261 | if (Security::config()->default_login_dest) { |
||
| 262 | return $this->redirect(Director::absoluteBaseURL() . Security::config()->default_login_dest); |
||
| 263 | } |
||
| 264 | |||
| 265 | // fallback to redirect back to home page |
||
| 266 | return $this->redirect(Director::absoluteBaseURL()); |
||
| 267 | } |
||
| 268 | |||
| 269 | /** |
||
| 270 | * If processing reaches here, then the user is authenticated but potentially not valid. We first need to confirm |
||
| 271 | * that they are not an attacker performing a SAML replay attack (capturing the raw traffic from a compromised |
||
| 272 | * device and then re-submitting the same SAML response). |
||
| 273 | * |
||
| 274 | * To combat this, we store SAML response IDs for the amount of time they're valid for (plus a configurable offset |
||
| 275 | * to account for potential time skew), and if the ID has been seen before we log an error message and return true |
||
| 276 | * (which indicates that this specific request is a replay attack). |
||
| 277 | * |
||
| 278 | * If no replay attack is detected, then the SAML response is logged so that future requests can be blocked. |
||
| 279 | * |
||
| 280 | * @param Auth $auth The Auth object that includes the processed response |
||
| 281 | * @param string $uniqueErrorId The error code to use when logging error messages for this given error |
||
| 282 | * @return bool true if this response is a replay attack, false if it's the first time we've seen the ID |
||
| 283 | */ |
||
| 284 | protected function checkForReplayAttack(Auth $auth, $uniqueErrorId = '') |
||
| 285 | { |
||
| 286 | $responseId = $auth->getLastMessageId(); |
||
| 287 | $expiry = $auth->getLastAssertionNotOnOrAfter(); // Note: Expiry will always be stored and returned in UTC |
||
| 288 | |||
| 289 | // Search for any SAMLResponse objects where the response ID is the same and the expiry is within the range |
||
| 290 | $count = SAMLResponse::get()->filter(['ResponseID' => $responseId])->count(); |
||
| 291 | |||
| 292 | if ($count > 0) { |
||
| 293 | // Response found, therefore this is a replay attack - log the error and return false so the user is denied |
||
| 294 | $this->getLogger()->error(sprintf( |
||
| 295 | '[%s] SAML replay attack detected! Response ID "%s", expires "%s", client IP "%s"', |
||
| 296 | $uniqueErrorId, |
||
| 297 | $responseId, |
||
| 298 | $expiry, |
||
| 299 | $this->getRequest()->getIP() |
||
| 300 | )); |
||
| 301 | |||
| 302 | return true; |
||
| 303 | } else { |
||
| 304 | // No attack detected, log the SAML response |
||
| 305 | $response = new SAMLResponse([ |
||
| 306 | 'ResponseID' => $responseId, |
||
| 307 | 'Expiry' => $expiry |
||
| 308 | ]); |
||
| 309 | |||
| 310 | $response->write(); |
||
| 311 | return false; |
||
| 312 | } |
||
| 313 | } |
||
| 314 | |||
| 315 | /** |
||
| 316 | * Get a logger |
||
| 317 | * |
||
| 318 | * @return LoggerInterface |
||
| 319 | */ |
||
| 320 | public function getLogger() |
||
| 321 | { |
||
| 322 | return Injector::inst()->get(LoggerInterface::class); |
||
| 323 | } |
||
| 324 | |||
| 325 | /** |
||
| 326 | * Gets the login form |
||
| 327 | * |
||
| 328 | * @return SAMLLoginForm |
||
| 329 | */ |
||
| 330 | public function getForm() |
||
| 331 | { |
||
| 332 | return Injector::inst()->get(SAMLLoginForm::class, true, [$this, SAMLAuthenticator::class, 'LoginForm']); |
||
| 333 | } |
||
| 334 | } |
||
| 335 |