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(), |
||
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 | // If we expect the NameID to be a binary version of the GUID (ADFS), check that it actually is |
||
127 | // If we are configured not to expect a binary NameID, then we assume it is a direct GUID (Azure AD) |
||
128 | if (Config::inst()->get(SAMLConfiguration::class, 'expect_binary_nameid')) { |
||
129 | $decodedNameId = base64_decode($auth->getNameId()); |
||
130 | if (ctype_print($decodedNameId)) { |
||
131 | $this->getForm()->sessionMessage('NameID from IdP is not a binary GUID.', ValidationResult::TYPE_ERROR); |
||
132 | $this->getRequest()->getSession()->save($this->getRequest()); |
||
133 | return $this->getRedirect(); |
||
134 | } |
||
135 | |||
136 | // transform the NameId to guid |
||
137 | $helper = SAMLHelper::singleton(); |
||
138 | $guid = $helper->binToStrGuid($decodedNameId); |
||
139 | } else { |
||
140 | $guid = $auth->getNameId(); |
||
141 | } |
||
142 | |||
143 | if (!$helper->validGuid($guid)) { |
||
0 ignored issues
–
show
Comprehensibility
Best Practice
introduced
by
Loading history...
|
|||
144 | $errorMessage = "Not a valid GUID '{$guid}' received from server."; |
||
145 | $this->getLogger()->error($errorMessage); |
||
146 | $this->getForm()->sessionMessage($errorMessage, ValidationResult::TYPE_ERROR); |
||
147 | $this->getRequest()->getSession()->save($this->getRequest()); |
||
148 | return $this->getRedirect(); |
||
149 | } |
||
150 | |||
151 | $this->extend('updateGuid', $guid); |
||
152 | |||
153 | $attributes = $auth->getAttributes(); |
||
154 | |||
155 | $fieldToClaimMap = array_flip(Member::config()->claims_field_mappings); |
||
156 | |||
157 | // Write a rudimentary member with basic fields on every login, so that we at least have something |
||
158 | // if there is no further sync (e.g. via LDAP) |
||
159 | $member = Member::get()->filter('GUID', $guid)->limit(1)->first(); |
||
160 | if (!($member && $member->exists()) |
||
161 | && Config::inst()->get(SAMLConfiguration::class, 'allow_insecure_email_linking') |
||
162 | && isset($fieldToClaimMap['Email']) |
||
163 | ) { |
||
164 | // If there is no member found via GUID and we allow linking via email, search by email |
||
165 | $member = Member::get()->filter('Email', $attributes[$fieldToClaimMap['Email']])->limit(1)->first(); |
||
166 | |||
167 | if (!($member && $member->exists())) { |
||
168 | $member = new Member(); |
||
169 | } |
||
170 | |||
171 | $member->GUID = $guid; |
||
172 | } elseif (!($member && $member->exists())) { |
||
173 | // If the member doesn't exist and we don't allow linking via email, then create a new member |
||
174 | $member = new Member(); |
||
175 | $member->GUID = $guid; |
||
176 | } |
||
177 | |||
178 | foreach ($member->config()->claims_field_mappings as $claim => $field) { |
||
179 | if (!isset($attributes[$claim][0])) { |
||
180 | $this->getLogger()->warning( |
||
181 | sprintf( |
||
182 | 'Claim rule \'%s\' configured in SAMLMemberExtension.claims_field_mappings, ' . |
||
183 | 'but wasn\'t passed through. Please check IdP claim rules.', |
||
184 | $claim |
||
185 | ) |
||
186 | ); |
||
187 | |||
188 | continue; |
||
189 | } |
||
190 | |||
191 | $member->$field = $attributes[$claim][0]; |
||
192 | } |
||
193 | |||
194 | $member->SAMLSessionIndex = $auth->getSessionIndex(); |
||
195 | |||
196 | // This will trigger LDAP update through LDAPMemberExtension::memberLoggedIn, if the LDAP module is installed. |
||
197 | // The LDAP update will also write the Member record a second time, but the member *must* be written before |
||
198 | // IdentityStore->logIn() is called, otherwise the identity store throws an exception. |
||
199 | // Both SAML and LDAP identify Members by the same GUID field. |
||
200 | $member->write(); |
||
201 | |||
202 | /** @var IdentityStore $identityStore */ |
||
203 | $identityStore = Injector::inst()->get(IdentityStore::class); |
||
204 | $identityStore->logIn($member, false, $this->getRequest()); |
||
205 | |||
206 | return $this->getRedirect(); |
||
207 | } |
||
208 | |||
209 | /** |
||
210 | * Generate this SP's metadata. This is needed for intialising the SP-IdP relationship. |
||
211 | * IdP is instructed to call us back here to establish the relationship. IdP may also be configured |
||
212 | * to hit this endpoint periodically during normal operation, to check the SP availability. |
||
213 | */ |
||
214 | public function metadata() |
||
215 | { |
||
216 | try { |
||
217 | /** @var Auth $auth */ |
||
218 | $auth = Injector::inst()->get(SAMLHelper::class)->getSAMLAuth(); |
||
219 | $settings = $auth->getSettings(); |
||
220 | $metadata = $settings->getSPMetadata(); |
||
221 | $errors = $settings->validateMetadata($metadata); |
||
222 | if (empty($errors)) { |
||
223 | header('Content-Type: text/xml'); |
||
224 | echo $metadata; |
||
225 | } else { |
||
226 | throw new Error( |
||
227 | 'Invalid SP metadata: ' . implode(', ', $errors), |
||
228 | Error::METADATA_SP_INVALID |
||
229 | ); |
||
230 | } |
||
231 | } catch (Exception $e) { |
||
232 | $this->getLogger()->error($e->getMessage()); |
||
233 | echo $e->getMessage(); |
||
234 | } |
||
235 | } |
||
236 | |||
237 | /** |
||
238 | * @return HTTPResponse |
||
239 | */ |
||
240 | protected function getRedirect() |
||
241 | { |
||
242 | // Absolute redirection URLs may cause spoofing |
||
243 | if ($this->getRequest()->getSession()->get('BackURL') |
||
244 | && Director::is_site_url($this->getRequest()->getSession()->get('BackURL'))) { |
||
245 | return $this->redirect($this->getRequest()->getSession()->get('BackURL')); |
||
246 | } |
||
247 | |||
248 | // Spoofing attack, redirect to homepage instead of spoofing url |
||
249 | if ($this->getRequest()->getSession()->get('BackURL') |
||
250 | && !Director::is_site_url($this->getRequest()->getSession()->get('BackURL'))) { |
||
251 | return $this->redirect(Director::absoluteBaseURL()); |
||
252 | } |
||
253 | |||
254 | // If a default login dest has been set, redirect to that. |
||
255 | if (Security::config()->default_login_dest) { |
||
256 | return $this->redirect(Director::absoluteBaseURL() . Security::config()->default_login_dest); |
||
257 | } |
||
258 | |||
259 | // fallback to redirect back to home page |
||
260 | return $this->redirect(Director::absoluteBaseURL()); |
||
261 | } |
||
262 | |||
263 | /** |
||
264 | * If processing reaches here, then the user is authenticated but potentially not valid. We first need to confirm |
||
265 | * that they are not an attacker performing a SAML replay attack (capturing the raw traffic from a compromised |
||
266 | * device and then re-submitting the same SAML response). |
||
267 | * |
||
268 | * To combat this, we store SAML response IDs for the amount of time they're valid for (plus a configurable offset |
||
269 | * to account for potential time skew), and if the ID has been seen before we log an error message and return true |
||
270 | * (which indicates that this specific request is a replay attack). |
||
271 | * |
||
272 | * If no replay attack is detected, then the SAML response is logged so that future requests can be blocked. |
||
273 | * |
||
274 | * @param Auth $auth The Auth object that includes the processed response |
||
275 | * @param string $uniqueErrorId The error code to use when logging error messages for this given error |
||
276 | * @return bool true if this response is a replay attack, false if it's the first time we've seen the ID |
||
277 | */ |
||
278 | protected function checkForReplayAttack(Auth $auth, $uniqueErrorId = '') |
||
279 | { |
||
280 | $responseId = $auth->getLastMessageId(); |
||
281 | $expiry = $auth->getLastAssertionNotOnOrAfter(); // Note: Expiry will always be stored and returned in UTC |
||
282 | |||
283 | // Search for any SAMLResponse objects where the response ID is the same and the expiry is within the range |
||
284 | $count = SAMLResponse::get()->filter(['ResponseID' => $responseId])->count(); |
||
285 | |||
286 | if ($count > 0) { |
||
287 | // Response found, therefore this is a replay attack - log the error and return false so the user is denied |
||
288 | $this->getLogger()->error(sprintf( |
||
289 | '[%s] SAML replay attack detected! Response ID "%s", expires "%s", client IP "%s"', |
||
290 | $uniqueErrorId, |
||
291 | $responseId, |
||
292 | $expiry, |
||
293 | $this->getRequest()->getIP() |
||
294 | )); |
||
295 | |||
296 | return true; |
||
297 | } else { |
||
298 | // No attack detected, log the SAML response |
||
299 | $response = new SAMLResponse([ |
||
300 | 'ResponseID' => $responseId, |
||
301 | 'Expiry' => $expiry |
||
302 | ]); |
||
303 | |||
304 | $response->write(); |
||
305 | return false; |
||
306 | } |
||
307 | } |
||
308 | |||
309 | /** |
||
310 | * Get a logger |
||
311 | * |
||
312 | * @return LoggerInterface |
||
313 | */ |
||
314 | public function getLogger() |
||
315 | { |
||
316 | return Injector::inst()->get(LoggerInterface::class); |
||
317 | } |
||
318 | |||
319 | /** |
||
320 | * Gets the login form |
||
321 | * |
||
322 | * @return SAMLLoginForm |
||
323 | */ |
||
324 | public function getForm() |
||
325 | { |
||
326 | return Injector::inst()->get(SAMLLoginForm::class, true, [$this, SAMLAuthenticator::class, 'LoginForm']); |
||
327 | } |
||
328 | } |
||
329 |