Commit | Line | Data |
---|---|---|
395539d7 AE |
1 | <?php |
2 | ||
3 | namespace SimpleSAML\Module\fsfdrupalauth\Auth\Source; | |
4 | ||
cf44092c AE |
5 | use Exception; |
6 | use PDO; | |
7 | use PDOException; | |
8 | use SimpleSAML\Error; | |
9 | use SimpleSAML\Logger; | |
10 | ||
395539d7 AE |
11 | /** |
12 | * Extension of simple SQL authentication source | |
13 | * | |
14 | * @package SimpleSAMLphp | |
15 | */ | |
16 | ||
17 | class FSFDrupalAuth extends \SimpleSAML\Module\core\Auth\UserPassBase | |
18 | { | |
19 | /** | |
20 | * The DSN we should connect to. | |
21 | */ | |
22 | private $dsn; | |
23 | ||
24 | /** | |
25 | * The username we should connect to the database with. | |
26 | */ | |
27 | private $username; | |
28 | ||
29 | /** | |
30 | * The password we should connect to the database with. | |
31 | */ | |
32 | private $password; | |
33 | ||
34 | /** | |
35 | * The options that we should connect to the database with. | |
36 | */ | |
37 | private $options; | |
38 | ||
39 | /** | |
40 | * The query we should use to retrieve the attributes for the user. | |
41 | * | |
42 | * The username and password will be available as :username and :password. | |
43 | */ | |
44 | private $query_main; | |
45 | private $query_membership; | |
46 | private $query_staff; | |
78e03f98 | 47 | private $query_nomination_process_donations; |
29854532 | 48 | private $query_nomination_process_gift_receipt; |
3fa64def | 49 | private $query_nomination_process_adhoc; |
395539d7 | 50 | |
c0d116a9 | 51 | /** |
e12acfe1 AE |
52 | * SQL query parameters, or variables that help determine which attributes |
53 | * someone has | |
c0d116a9 | 54 | */ |
e12acfe1 | 55 | private $fsf_org_id; |
e9db6ecd AE |
56 | private $gift_redeem_page_id; |
57 | ||
2d61361e | 58 | private $nomination_process_active; |
78e03f98 AE |
59 | private $nomination_process_contrib_start_date; |
60 | private $nomination_process_contrib_end_date; | |
e9db6ecd | 61 | private $nomination_process_adhoc_access_group_id; |
f38130e1 AE |
62 | private $membership_monthly_rate; |
63 | private $student_membership_monthly_rate; | |
c0d116a9 | 64 | |
395539d7 AE |
65 | /** |
66 | * Constructor for this authentication source. | |
67 | * | |
68 | * @param array $info Information about this authentication source. | |
69 | * @param array $config Configuration. | |
70 | */ | |
71 | public function __construct($info, $config) | |
72 | { | |
73 | assert(is_array($info)); | |
74 | assert(is_array($config)); | |
75 | ||
76 | // Call the parent constructor first, as required by the interface | |
77 | parent::__construct($info, $config); | |
78 | ||
79 | // Make sure that all required parameters are present. | |
e9db6ecd AE |
80 | foreach (['dsn', |
81 | 'username', | |
82 | 'password', | |
83 | ||
84 | 'query_main', | |
85 | 'query_membership', | |
86 | 'query_staff', | |
87 | ||
e12acfe1 | 88 | 'query_nomination_process_donations', |
3fa64def | 89 | 'query_nomination_process_gift_receipt', |
e9db6ecd AE |
90 | 'query_nomination_process_adhoc', |
91 | ||
92 | 'fsf_org_id', | |
93 | 'gift_redeem_page_id', | |
94 | ||
2d61361e | 95 | 'nomination_process_active', |
e12acfe1 | 96 | 'nomination_process_contrib_start_date', |
e9db6ecd AE |
97 | 'nomination_process_contrib_end_date', |
98 | 'nomination_process_adhoc_access_group_id', | |
99 | 'membership_monthly_rate', | |
100 | 'student_membership_monthly_rate'] | |
3fa64def | 101 | as $param) { |
e12acfe1 | 102 | |
395539d7 | 103 | if (!array_key_exists($param, $config)) { |
cf44092c | 104 | throw new Exception('Missing required attribute \''.$param. |
395539d7 AE |
105 | '\' for authentication source '.$this->authId); |
106 | } | |
107 | ||
108 | if (!is_string($config[$param])) { | |
cf44092c | 109 | throw new Exception('Expected parameter \''.$param. |
395539d7 AE |
110 | '\' for authentication source '.$this->authId. |
111 | ' to be a string. Instead it was: '. | |
112 | var_export($config[$param], true)); | |
113 | } | |
e12acfe1 AE |
114 | |
115 | $this->$param = $config[$param]; | |
395539d7 AE |
116 | } |
117 | ||
395539d7 AE |
118 | if (isset($config['options'])) { |
119 | $this->options = $config['options']; | |
120 | } | |
121 | } | |
122 | ||
123 | ||
124 | /** | |
125 | * Create a database connection. | |
126 | * | |
f58b2b6b | 127 | * @return PDO The database connection. |
395539d7 AE |
128 | */ |
129 | private function connect() | |
130 | { | |
131 | try { | |
cf44092c AE |
132 | $db = new PDO($this->dsn, $this->username, $this->password, $this->options); |
133 | } catch (PDOException $e) { | |
134 | // Obfuscate the password if it's part of the dsn | |
135 | $obfuscated_dsn = preg_replace('/(user|password)=(.*?([;]|$))/', '${1}=***', $this->dsn); | |
136 | ||
137 | throw new Exception('fsfdrupalauth:' . $this->authId . ': - Failed to connect to \'' . | |
138 | $obfuscated_dsn . '\': ' . $e->getMessage()); | |
395539d7 AE |
139 | } |
140 | ||
cf44092c | 141 | $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); |
395539d7 AE |
142 | |
143 | $driver = explode(':', $this->dsn, 2); | |
144 | $driver = strtolower($driver[0]); | |
145 | ||
146 | // Driver specific initialization | |
147 | switch ($driver) { | |
148 | case 'mysql': | |
149 | // Use UTF-8 | |
150 | $db->exec("SET NAMES 'utf8mb4'"); | |
151 | break; | |
152 | case 'pgsql': | |
153 | // Use UTF-8 | |
154 | $db->exec("SET NAMES 'UTF8'"); | |
155 | break; | |
156 | } | |
157 | ||
158 | return $db; | |
159 | } | |
160 | ||
161 | /* | |
162 | * Check the password against a Drupal hash | |
163 | * | |
164 | */ | |
165 | private function check_password($password, $hash) { | |
166 | ||
167 | // | |
168 | // The reason for running a separate process is so that the PHP global | |
169 | // env doesn't get clobbered by include / require. | |
170 | // | |
171 | ||
172 | // pipes code based off of https://www.php.net/manual/en/function.proc-open.php | |
173 | // CC-BY 3.0 or later | |
174 | $descriptorspec = array( | |
59f90414 AE |
175 | 0 => array("pipe", "r"), // stdin is a pipe that the child may read from |
176 | 1 => array("pipe", "w"), // stdout is a pipe that the child may write to | |
177 | 2 => array("pipe", "w") // stderr is a pipe that the child may write to | |
395539d7 AE |
178 | ); |
179 | ||
180 | $cwd = "../modules/fsfdrupalauth/extlib"; | |
181 | //$env = array('some_option' => 'aeiou'); | |
182 | $env = array(); | |
183 | ||
184 | $process = proc_open('php drupal-pw-check.php', $descriptorspec, $pipes, $cwd, $env); | |
185 | ||
186 | if (is_resource($process)) { | |
187 | // $pipes now looks like this: | |
188 | // 0 => writeable handle connected to child stdin | |
189 | // 1 => readable handle connected to child stdout | |
190 | ||
191 | fwrite($pipes[0], json_encode([$password, $hash])); | |
192 | fclose($pipes[0]); | |
193 | ||
194 | $result = stream_get_contents($pipes[1]); | |
195 | fclose($pipes[1]); | |
196 | ||
197 | $errors = stream_get_contents($pipes[2]); | |
198 | fclose($pipes[2]); | |
199 | ||
200 | // It is important that you close any pipes before calling | |
201 | // proc_close in order to avoid a deadlock | |
202 | $return_value = proc_close($process); | |
203 | ||
cf44092c | 204 | //Logger::debug('fsfdrupalauth:'.$this->authId.': authenticator stdout: '.$result); |
3fa64def | 205 | |
e57e2393 | 206 | $errors_found_yet = false; |
395539d7 | 207 | if ($errors != "") { |
cf44092c | 208 | Logger::error('fsfdrupalauth:'.$this->authId.': authenticator stderr: '.$errors); |
e57e2393 | 209 | $errors_found_yet = true; |
395539d7 | 210 | } |
3fa64def | 211 | |
395539d7 | 212 | if ($return_value != 0) { |
cf44092c | 213 | Logger::error('fsfdrupalauth:'.$this->authId.': authenticator non-zero return code: '.$return_value); |
e57e2393 | 214 | $errors_found_yet = true; |
395539d7 | 215 | } |
3fa64def | 216 | |
6921b9d4 | 217 | return (!$errors_found_yet && is_string($result) && rtrim($result) == "true"); |
395539d7 AE |
218 | |
219 | } else { | |
220 | ||
cf44092c | 221 | Logger::error('fsfdrupalauth:'.$this->authId.': unable to launch authenticator'); |
395539d7 AE |
222 | |
223 | return false; | |
224 | } | |
225 | } | |
226 | ||
227 | /** | |
228 | * | |
229 | * query the database with arbitrary queries that only require a user name. | |
230 | * | |
231 | */ | |
2e644466 | 232 | private function query_db($queryname, $query_params) |
395539d7 AE |
233 | { |
234 | assert(is_string($queryname)); | |
235 | assert(is_string($username)); | |
236 | ||
237 | $db = $this->connect(); | |
238 | ||
239 | try { | |
240 | $sth = $db->prepare($this->$queryname); | |
cf44092c AE |
241 | } catch (PDOException $e) { |
242 | throw new Exception('fsfdrupalauth:'.$this->authId. | |
395539d7 AE |
243 | ': - Failed to prepare queryname: '.$queryname.': '.$e->getMessage()); |
244 | } | |
245 | ||
246 | try { | |
2e644466 | 247 | $sth->execute($query_params); |
cf44092c AE |
248 | } catch (PDOException $e) { |
249 | throw new Exception('fsfdrupalauth:'.$this->authId. | |
395539d7 AE |
250 | ': - Failed to execute queryname: '.$queryname.': '.$e->getMessage()); |
251 | } | |
252 | ||
253 | try { | |
f58b2b6b | 254 | $data = $sth->fetchAll(PDO::FETCH_ASSOC); |
cf44092c AE |
255 | } catch (PDOException $e) { |
256 | throw new Exception('fsfdrupalauth:'.$this->authId. | |
395539d7 AE |
257 | ': - Failed to fetch result set: '.$e->getMessage()); |
258 | } | |
259 | ||
cf44092c | 260 | Logger::info('fsfdrupalauth:'.$this->authId.': Got '.count($data). |
395539d7 AE |
261 | ' rows from database'); |
262 | ||
263 | return $data; | |
264 | } | |
265 | ||
266 | /** | |
267 | * add more CAS attributes to user, such as is_staff and is_member | |
268 | */ | |
269 | private function add_more_attributes(&$attributes, $username) { | |
270 | ||
f33432a6 AE |
271 | // |
272 | // query on membership | |
273 | // | |
395539d7 | 274 | |
2e644466 | 275 | $membership_data = $this->query_db('query_membership', ['username' => $username]); |
395539d7 AE |
276 | |
277 | if (count($membership_data) === 0) { | |
278 | // No rows returned - invalid username | |
cf44092c | 279 | Logger::debug('fsfdrupalauth:'.$this->authId. |
395539d7 AE |
280 | ': No rows in result set. Probably no membership.'); |
281 | } | |
282 | ||
f33432a6 | 283 | $attributes['is_member'] = ['false']; |
395539d7 AE |
284 | $attributes['was_member'] = ['false']; |
285 | ||
286 | foreach ($membership_data as $row) { | |
287 | foreach ($row as $key => $value) { | |
288 | if ($value === null) { | |
289 | continue; | |
290 | } | |
291 | $value = (string) $value; | |
292 | ||
f33432a6 | 293 | if ($value === '1' || $value === '2' || $value === '3') { |
395539d7 AE |
294 | $attributes['is_member'] = ['true']; |
295 | $attributes['was_member'] = ['true']; | |
f33432a6 | 296 | } elseif ($value === '4') { |
395539d7 | 297 | $attributes['was_member'] = ['true']; |
f33432a6 | 298 | } |
395539d7 AE |
299 | } |
300 | } | |
301 | ||
c0d116a9 | 302 | // |
0782e0aa | 303 | // helper functions for access to board nomination / discussion process |
c0d116a9 AE |
304 | // |
305 | ||
29854532 AE |
306 | /** |
307 | * @param string $query_name Name of query in authsources | |
f38130e1 | 308 | * @param array $extra_params Associative array of parameters to include in query |
29854532 | 309 | */ |
f38130e1 AE |
310 | $donation_query = function ($query_name, $extra_params) |
311 | use ($username) { | |
29854532 | 312 | |
3fa64def | 313 | $parameters = ['username' => $username]; |
29854532 | 314 | |
3fa64def AE |
315 | foreach ($extra_params as $key => $value) { |
316 | $parameters[$key] = $value; | |
29854532 | 317 | } |
78e03f98 | 318 | |
f38130e1 AE |
319 | return $this->query_db($query_name, $parameters); |
320 | }; | |
78e03f98 | 321 | |
f38130e1 | 322 | $compare_res = function ($result, $amount) { |
29854532 | 323 | foreach ($result[0] as $key => $value) { |
f38130e1 AE |
324 | if (intval($value) >= $amount) { |
325 | return true; | |
326 | } | |
327 | } | |
328 | return false; | |
329 | }; | |
330 | ||
331 | // looks for memberships / comparable donations in time window. also | |
332 | // looks for a membership or donation (included as a param) that | |
333 | // occurred up to a year before, and that would have carried over into | |
334 | // the time window with a single donation. this approximates whether | |
335 | // the person was, or would have been, a member during the configured | |
336 | // time window. | |
0782e0aa | 337 | $nomination_process_analyze_history = function ($selective_donations_history) |
f38130e1 AE |
338 | use ($start_date, $end_date) { |
339 | ||
340 | $eligible = false; | |
341 | ||
342 | $start_date_obj = new \DateTime($start_date); | |
343 | $end_date_obj = new \DateTime($end_date); | |
344 | ||
345 | foreach ($selective_donations_history as $row) { | |
346 | ||
347 | $amount = intval($row['amount']); | |
348 | $member_type_id = $row['member_type_id']; | |
349 | $receive_date_obj = new \DateTime($row['receive_date']); | |
350 | ||
351 | if ($amount < 5) { | |
352 | continue; | |
353 | ||
354 | } elseif ($receive_date_obj >= $start_date_obj and $receive_date_obj <= $end_date_obj) { | |
355 | return true; | |
356 | ||
357 | } elseif ($receive_date_obj < $start_date_obj) { | |
358 | switch ($member_type_id) { | |
359 | case '1': | |
360 | case '2': | |
361 | $rate = intval($this->student_membership_monthly_rate); | |
362 | break; | |
363 | case '8': | |
364 | case '9': | |
365 | case null: | |
366 | default: | |
367 | $rate = intval($this->membership_monthly_rate); | |
368 | break; | |
369 | } | |
370 | $membership_end_date_obj = new \DateTime($row['receive_date']); | |
371 | $membership_end_date_obj->add(new \DateInterval("P" . ceil($amount / $rate) . "M")); | |
372 | ||
373 | if ($membership_end_date_obj >= $start_date_obj) { | |
374 | return true; | |
375 | } | |
376 | } | |
78e03f98 | 377 | } |
78e03f98 AE |
378 | return false; |
379 | }; | |
380 | ||
0782e0aa AE |
381 | // |
382 | // nomination form participation specific checks | |
383 | // | |
384 | ||
385 | $start_date = $this->nomination_process_contrib_start_date; | |
386 | $end_date = $this->nomination_process_contrib_end_date; | |
387 | ||
f38130e1 | 388 | $donation_params = ['start_date' => $start_date, 'end_date' => $end_date]; |
ccf1684d | 389 | $gift_member_params = ['start_date' => $start_date, 'end_date' => $end_date, 'gift_redeem_page_id' => intval($this->gift_redeem_page_id)]; |
e9db6ecd | 390 | $adhoc_params = ['adhoc_access_group_id' => intval($this->nomination_process_adhoc_access_group_id)]; |
3fa64def | 391 | |
2d61361e | 392 | if ($this->nomination_process_active == 'true' ) { |
0782e0aa AE |
393 | if ($compare_res($donation_query('query_nomination_process_adhoc', $adhoc_params), 1)) { |
394 | $attributes['nomination_process'] = ['true']; | |
395 | ||
396 | } elseif ($attributes['is_member'] == ['true'] | |
397 | && ($nomination_process_analyze_history($donation_query('query_nomination_process_donations', $donation_params)) | |
398 | || $compare_res($donation_query('query_nomination_process_gift_receipt', $gift_member_params), 1))) { | |
29854532 | 399 | |
2d61361e AE |
400 | $attributes['nomination_process'] = ['true']; |
401 | } else { | |
402 | Logger::debug('fsfdrupalauth:'.$this->authId. | |
403 | ': Not a member / comparable donor during window for board process.'); | |
404 | $attributes['nomination_process'] = ['false']; | |
405 | } | |
78e03f98 AE |
406 | } else { |
407 | $attributes['nomination_process'] = ['false']; | |
408 | } | |
c0d116a9 | 409 | |
f33432a6 AE |
410 | // |
411 | // query on staff | |
412 | // | |
395539d7 | 413 | |
e12acfe1 | 414 | $staff_data = $this->query_db('query_staff', ['username' => $username, 'fsf_org_id' => $this->fsf_org_id]); |
395539d7 AE |
415 | |
416 | if (count($staff_data) === 0) { | |
417 | // No rows returned - invalid username | |
cf44092c | 418 | Logger::debug('fsfdrupalauth:'.$this->authId. |
395539d7 AE |
419 | ': No rows in result set. Probably not FSF staff.'); |
420 | } | |
421 | ||
f33432a6 | 422 | $attributes['is_fsf_staff'] = ['false']; |
395539d7 AE |
423 | |
424 | foreach ($staff_data as $row) { | |
425 | foreach ($row as $key => $value) { | |
426 | ||
427 | if ($value === null) { | |
428 | continue; | |
429 | } | |
430 | $value = (string) $value; | |
431 | ||
bdc16e06 | 432 | if (strtolower($value) === strtolower($username)) { |
395539d7 | 433 | // they are staff |
bdc16e06 | 434 | $attributes['is_fsf_staff'] = ['true']; |
395539d7 AE |
435 | break; |
436 | } | |
437 | } | |
438 | } | |
429471b4 AE |
439 | |
440 | // | |
441 | // aggregate attribute | |
442 | // | |
443 | ||
444 | $groups_list = ''; | |
445 | $first = true; | |
446 | foreach ($attributes as $key => $value) { | |
447 | if ($value == ['true']) { | |
448 | if (!$first) { | |
449 | $groups_list .= ', '; | |
450 | } | |
451 | $groups_list .= $key; | |
452 | $first = false; | |
453 | } | |
454 | } | |
455 | ||
456 | $attributes['groups_list'] = [$groups_list]; | |
395539d7 AE |
457 | } |
458 | ||
459 | /** | |
460 | * Attempt to log in using the given username and password. | |
461 | * | |
462 | * On a successful login, this function should return the users attributes. On failure, | |
463 | * it should throw an exception. If the error was caused by the user entering the wrong | |
cf44092c | 464 | * username or password, a Error\Error('WRONGUSERPASS') should be thrown. |
395539d7 AE |
465 | * |
466 | * Note that both the username and the password are UTF-8 encoded. | |
467 | * | |
468 | * @param string $username The username the user wrote. | |
469 | * @param string $password The password the user wrote. | |
470 | * @return array Associative array with the users attributes. | |
471 | */ | |
472 | protected function login($username, $password) | |
473 | { | |
474 | assert(is_string($username)); | |
475 | assert(is_string($password)); | |
476 | ||
477 | //// keep this commented when it's not in use. it prints user passwords to the log file | |
cf44092c | 478 | //Logger::debug('fsfdrupalauth:'.$this->authId.': entered password: '.$password); |
395539d7 AE |
479 | |
480 | ||
2e644466 | 481 | $user_data = $this->query_db('query_main', ['username' => $username]); |
395539d7 AE |
482 | |
483 | ||
484 | if (count($user_data) === 0) { | |
485 | // No rows returned - invalid username | |
cf44092c | 486 | Logger::error('fsfdrupalauth:'.$this->authId. |
395539d7 | 487 | ': No rows in result set. Probably wrong username.'); |
cf44092c | 488 | throw new Error\Error('WRONGUSERPASS'); |
395539d7 AE |
489 | } |
490 | ||
491 | /* Extract attributes. We allow the resultset to consist of multiple rows. Attributes | |
492 | * which are present in more than one row will become multivalued. null values and | |
493 | * duplicate values will be skipped. All values will be converted to strings. | |
494 | */ | |
495 | $attributes = []; | |
496 | ||
f33432a6 AE |
497 | // use the entered user name so we don't forcibly change it to all |
498 | // lower case. this is to preserve the behavior of the old cas server, | |
499 | // and to remain compatible with our MW and Discourse sites that are | |
500 | // case sensitive. | |
501 | $attributes['name'][] = $username; | |
395539d7 AE |
502 | |
503 | foreach ($user_data as $row) { | |
504 | foreach ($row as $key => $value) { | |
505 | if ($value === null) { | |
506 | continue; | |
507 | } | |
508 | ||
509 | $value = (string) $value; | |
510 | ||
511 | if (!array_key_exists($key, $attributes)) { | |
512 | $attributes[$key] = []; | |
513 | } | |
514 | ||
515 | if (in_array($value, $attributes[$key], true)) { | |
516 | // Value already exists in attribute | |
517 | continue; | |
518 | } | |
519 | ||
520 | $attributes[$key][] = $value; | |
521 | } | |
522 | } | |
523 | ||
524 | if (!$this->check_password($password, $attributes['pass'][0])) { | |
cf44092c | 525 | throw new Error\Error('WRONGUSERPASS'); |
395539d7 AE |
526 | } |
527 | ||
528 | unset($attributes['pass']); | |
529 | ||
530 | ||
531 | $this->add_more_attributes($attributes, $username); | |
532 | ||
533 | ||
cf44092c | 534 | Logger::info('fsfdrupalauth:'.$this->authId.': Attributes: '. |
395539d7 AE |
535 | implode(',', array_keys($attributes))); |
536 | ||
537 | return $attributes; | |
538 | } | |
539 | } |