29941d69fcf3819bfb41187a833a9f4f1d4376d9
[fsfdrupalauth.git] / lib / Auth / Source / FSFDrupalAuth.php
1 <?php
2
3 namespace SimpleSAML\Module\fsfdrupalauth\Auth\Source;
4
5 use Exception;
6 use PDO;
7 use PDOException;
8 use SimpleSAML\Error;
9 use SimpleSAML\Logger;
10
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;
47
48 private $query_nomination_process_donations;
49 private $query_nomination_process_gift_receipt;
50 private $query_nomination_process_adhoc;
51
52 private $query_discussion_process_old_membership;
53 private $query_discussion_process_donations;
54 private $query_discussion_process_adhoc;
55
56 /**
57 * SQL query parameters, or variables that help determine which attributes
58 * someone has
59 */
60 private $fsf_org_id;
61 private $gift_redeem_page_id;
62
63 private $nomination_process_active;
64 private $nomination_process_contrib_start_date;
65 private $nomination_process_contrib_end_date;
66 private $nomination_process_adhoc_access_group_id;
67 private $membership_monthly_rate;
68 private $student_membership_monthly_rate;
69
70 private $discussion_process_active;
71 private $discussion_process_contrib_start_date;
72 private $discussion_process_contrib_end_date;
73 private $discussion_process_adhoc_access_group_id;
74 private $discussion_process_adhoc_no_access_group_id;
75 private $discussion_process_donation_amount;
76
77 private $discussion_moderator_access_group_id;
78
79 /**
80 * Constructor for this authentication source.
81 *
82 * @param array $info Information about this authentication source.
83 * @param array $config Configuration.
84 */
85 public function __construct($info, $config)
86 {
87 assert(is_array($info));
88 assert(is_array($config));
89
90 // Call the parent constructor first, as required by the interface
91 parent::__construct($info, $config);
92
93 // Make sure that all required parameters are present.
94 foreach (['dsn',
95 'username',
96 'password',
97
98 'query_main',
99 'query_membership',
100 'query_staff',
101
102 'query_nomination_process_donations',
103 'query_nomination_process_gift_receipt',
104 'query_nomination_process_adhoc',
105
106 'fsf_org_id',
107 'gift_redeem_page_id',
108
109 'nomination_process_active',
110 'nomination_process_contrib_start_date',
111 'nomination_process_contrib_end_date',
112 'nomination_process_adhoc_access_group_id',
113 'membership_monthly_rate',
114 'student_membership_monthly_rate',
115
116 'query_discussion_process_old_membership',
117 'query_discussion_process_donations',
118 'query_discussion_process_adhoc',
119
120 'discussion_process_active',
121 'discussion_process_contrib_start_date',
122 'discussion_process_contrib_end_date',
123 'discussion_process_adhoc_access_group_id',
124 'discussion_process_adhoc_no_access_group_id',
125 'discussion_process_donation_amount',
126
127 'discussion_moderator_access_group_id',]
128 as $param) {
129
130 if (!array_key_exists($param, $config)) {
131 throw new Exception('Missing required attribute \''.$param.
132 '\' for authentication source '.$this->authId);
133 }
134
135 if (!is_string($config[$param])) {
136 throw new Exception('Expected parameter \''.$param.
137 '\' for authentication source '.$this->authId.
138 ' to be a string. Instead it was: '.
139 var_export($config[$param], true));
140 }
141
142 $this->$param = $config[$param];
143 }
144
145 if (isset($config['options'])) {
146 $this->options = $config['options'];
147 }
148 }
149
150
151 /**
152 * Create a database connection.
153 *
154 * @return PDO The database connection.
155 */
156 private function connect()
157 {
158 try {
159 $db = new PDO($this->dsn, $this->username, $this->password, $this->options);
160 } catch (PDOException $e) {
161 // Obfuscate the password if it's part of the dsn
162 $obfuscated_dsn = preg_replace('/(user|password)=(.*?([;]|$))/', '${1}=***', $this->dsn);
163
164 throw new Exception('fsfdrupalauth:' . $this->authId . ': - Failed to connect to \'' .
165 $obfuscated_dsn . '\': ' . $e->getMessage());
166 }
167
168 $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
169
170 $driver = explode(':', $this->dsn, 2);
171 $driver = strtolower($driver[0]);
172
173 // Driver specific initialization
174 switch ($driver) {
175 case 'mysql':
176 // Use UTF-8
177 $db->exec("SET NAMES 'utf8mb4'");
178 break;
179 case 'pgsql':
180 // Use UTF-8
181 $db->exec("SET NAMES 'UTF8'");
182 break;
183 }
184
185 return $db;
186 }
187
188 /*
189 * Check the password against a Drupal hash
190 *
191 */
192 private function check_password($password, $hash) {
193
194 //
195 // The reason for running a separate process is so that the PHP global
196 // env doesn't get clobbered by include / require.
197 //
198
199 // pipes code based off of https://www.php.net/manual/en/function.proc-open.php
200 // CC-BY 3.0 or later
201 $descriptorspec = array(
202 0 => array("pipe", "r"), // stdin is a pipe that the child may read from
203 1 => array("pipe", "w"), // stdout is a pipe that the child may write to
204 2 => array("pipe", "w") // stderr is a pipe that the child may write to
205 );
206
207 $cwd = "../modules/fsfdrupalauth/extlib";
208 //$env = array('some_option' => 'aeiou');
209 $env = array();
210
211 $process = proc_open('php drupal-pw-check.php', $descriptorspec, $pipes, $cwd, $env);
212
213 if (is_resource($process)) {
214 // $pipes now looks like this:
215 // 0 => writeable handle connected to child stdin
216 // 1 => readable handle connected to child stdout
217
218 fwrite($pipes[0], json_encode([$password, $hash]));
219 fclose($pipes[0]);
220
221 $result = stream_get_contents($pipes[1]);
222 fclose($pipes[1]);
223
224 $errors = stream_get_contents($pipes[2]);
225 fclose($pipes[2]);
226
227 // It is important that you close any pipes before calling
228 // proc_close in order to avoid a deadlock
229 $return_value = proc_close($process);
230
231 //Logger::debug('fsfdrupalauth:'.$this->authId.': authenticator stdout: '.$result);
232
233 $errors_found_yet = false;
234 if ($errors != "") {
235 Logger::error('fsfdrupalauth:'.$this->authId.': authenticator stderr: '.$errors);
236 $errors_found_yet = true;
237 }
238
239 if ($return_value != 0) {
240 Logger::error('fsfdrupalauth:'.$this->authId.': authenticator non-zero return code: '.$return_value);
241 $errors_found_yet = true;
242 }
243
244 return (!$errors_found_yet && is_string($result) && rtrim($result) == "true");
245
246 } else {
247
248 Logger::error('fsfdrupalauth:'.$this->authId.': unable to launch authenticator');
249
250 return false;
251 }
252 }
253
254 /**
255 *
256 * query the database with arbitrary queries that only require a user name.
257 *
258 */
259 private function query_db($queryname, $query_params)
260 {
261 assert(is_string($queryname));
262 assert(is_string($username));
263
264 $db = $this->connect();
265
266 try {
267 $sth = $db->prepare($this->$queryname);
268 } catch (PDOException $e) {
269 throw new Exception('fsfdrupalauth:'.$this->authId.
270 ': - Failed to prepare queryname: '.$queryname.': '.$e->getMessage());
271 }
272
273 try {
274 $sth->execute($query_params);
275 } catch (PDOException $e) {
276 throw new Exception('fsfdrupalauth:'.$this->authId.
277 ': - Failed to execute queryname: '.$queryname.': '.$e->getMessage());
278 }
279
280 try {
281 $data = $sth->fetchAll(PDO::FETCH_ASSOC);
282 } catch (PDOException $e) {
283 throw new Exception('fsfdrupalauth:'.$this->authId.
284 ': - Failed to fetch result set: '.$e->getMessage());
285 }
286
287 Logger::info('fsfdrupalauth:'.$this->authId.': Got '.count($data).
288 ' rows from database');
289
290 return $data;
291 }
292
293 /**
294 * add more CAS attributes to user, such as is_staff and is_member
295 */
296 private function add_more_attributes(&$attributes, $username) {
297
298 //
299 // query on staff
300 //
301
302 $staff_data = $this->query_db('query_staff', ['username' => $username, 'fsf_org_id' => $this->fsf_org_id]);
303
304 if (count($staff_data) === 0) {
305 // No rows returned - invalid username
306 Logger::debug('fsfdrupalauth:'.$this->authId.
307 ': No rows in result set. Probably not FSF staff.');
308 }
309
310 $attributes['is_fsf_staff'] = ['false'];
311
312 foreach ($staff_data as $row) {
313 foreach ($row as $key => $value) {
314
315 if ($value === null) {
316 continue;
317 }
318 $value = (string) $value;
319
320 if (strtolower($value) === strtolower($username)) {
321 // they are staff
322 $attributes['is_fsf_staff'] = ['true'];
323 break;
324 }
325 }
326 }
327
328 //
329 // query on membership
330 //
331
332 $membership_data = $this->query_db('query_membership', ['username' => $username]);
333
334 if (count($membership_data) === 0) {
335 // No rows returned - invalid username
336 Logger::debug('fsfdrupalauth:'.$this->authId.
337 ': No rows in result set. Probably no membership.');
338 }
339
340 $attributes['is_member'] = ['false'];
341 $attributes['was_member'] = ['false'];
342
343 foreach ($membership_data as $row) {
344 foreach ($row as $key => $value) {
345 if ($value === null) {
346 continue;
347 }
348 $value = (string) $value;
349
350 if ($value === '1' || $value === '2' || $value === '3') {
351 $attributes['is_member'] = ['true'];
352 $attributes['was_member'] = ['true'];
353 } elseif ($value === '4') {
354 $attributes['was_member'] = ['true'];
355 }
356 }
357 }
358
359 //
360 // helper functions for access to board nomination / discussion process
361 //
362
363 /**
364 * @param string $query_name Name of query in authsources
365 * @param array $extra_params Associative array of parameters to include in query
366 */
367 $donation_query = function ($query_name, $extra_params)
368 use ($username) {
369
370 $parameters = ['username' => $username];
371
372 foreach ($extra_params as $key => $value) {
373 $parameters[$key] = $value;
374 }
375
376 return $this->query_db($query_name, $parameters);
377 };
378
379 $old_membership_query = $donation_query;
380
381 $compare_res = function ($result, $amount) {
382 foreach ($result[0] as $key => $value) {
383 if (intval($value) >= $amount) {
384 return true;
385 }
386 }
387 return false;
388 };
389
390 // set dates here, used by helper functions below
391 $nomination_process_start_date = $this->nomination_process_contrib_start_date;
392 $nomination_process_end_date = $this->nomination_process_contrib_end_date;
393 $discussion_process_start_date = $this->discussion_process_contrib_start_date;
394 $discussion_process_end_date = $this->discussion_process_contrib_end_date;
395
396
397 // looks for memberships / comparable donations in time window. also
398 // looks for a membership or donation (included as a param) that
399 // occurred up to a year before, and that would have carried over into
400 // the time window with a single donation. this approximates whether
401 // the person was, or would have been, a member during the configured
402 // time window.
403 $nomination_process_analyze_history = function ($selective_donations_history)
404 use ($nomination_process_start_date, $nomination_process_end_date) {
405
406 $eligible = false;
407
408 Logger::debug('fsfdrupalauth:'.$this->authId.
409 ': start date: '.$nomination_process_start_date. " end date: ".$nomination_process_end_date);
410
411 $start_date_obj = new \DateTime($nomination_process_start_date);
412 $end_date_obj = new \DateTime($nomination_process_end_date);
413
414 foreach ($selective_donations_history as $row) {
415
416 $amount = intval($row['amount']);
417 $member_type_id = $row['member_type_id'];
418 $receive_date_obj = new \DateTime($row['receive_date']);
419
420 if ($amount < 5) {
421 continue;
422
423 } elseif ($receive_date_obj >= $start_date_obj and $receive_date_obj <= $end_date_obj) {
424 return true;
425
426 } elseif ($receive_date_obj < $start_date_obj) {
427 switch ($member_type_id) {
428 case '1':
429 case '2':
430 $rate = intval($this->student_membership_monthly_rate);
431 break;
432 case '8':
433 case '9':
434 case null:
435 default:
436 $rate = intval($this->membership_monthly_rate);
437 break;
438 }
439 $membership_end_date_obj = new \DateTime($row['receive_date']);
440 $membership_end_date_obj->add(new \DateInterval("P" . ceil($amount / $rate) . "M"));
441
442 if ($membership_end_date_obj >= $start_date_obj) {
443 return true;
444 }
445 }
446 }
447 return false;
448 };
449
450 $discussion_process_analyze_history = function ($selective_donations_history)
451 use ($discussion_process_start_date, $discussion_process_end_date) {
452
453 $eligible = false;
454 $total = 0;
455
456 Logger::debug('fsfdrupalauth:'.$this->authId.
457 ': start date: '.$discussion_process_start_date. " end date: ".$discussion_process_end_date);
458
459 $start_date_obj = new \DateTime($discussion_process_start_date);
460 $end_date_obj = new \DateTime($discussion_process_end_date);
461
462 foreach ($selective_donations_history as $row) {
463
464 $amount = intval($row['amount']);
465 $member_type_id = $row['member_type_id'];
466 $receive_date_obj = new \DateTime($row['receive_date']);
467
468 if (($receive_date_obj > $start_date_obj) && ($receive_date_obj < $end_date_obj)) {
469 $total += $amount;
470 }
471 }
472
473 Logger::debug('fsfdrupalauth:'.$this->authId.
474 ': total amount: $'.$total);
475
476 if ($total >= $this->discussion_process_donation_amount) {
477 return true;
478 } else {
479 return false;
480 }
481 };
482
483 //
484 // nomination form participation specific checks
485 //
486
487 $donation_params = ['start_date' => $nomination_process_start_date, 'end_date' => $nomination_process_end_date];
488 $gift_member_params = ['start_date' => $nomination_process_start_date, 'end_date' => $nomination_process_end_date, 'gift_redeem_page_id' => intval($this->gift_redeem_page_id)];
489 $adhoc_params = ['adhoc_access_group_id' => intval($this->nomination_process_adhoc_access_group_id)];
490
491 if ($this->nomination_process_active != 'true' ) {
492 Logger::debug('fsfdrupalauth:'.$this->authId.': Nomination board process checks not active');
493 $attributes['nomination_process'] = ['false'];
494
495 } elseif ($compare_res($donation_query('query_nomination_process_adhoc', $adhoc_params), 1)) {
496 Logger::debug('fsfdrupalauth:'.$this->authId.': In adhoc list of contacts for nomination board process');
497 $attributes['nomination_process'] = ['true'];
498
499 } elseif ($attributes['is_member'] != ['true']) {
500 Logger::debug('fsfdrupalauth:'.$this->authId.': Not a current member for nomination board process');
501 $attributes['nomination_process'] = ['false'];
502
503 } elseif ($nomination_process_analyze_history($donation_query('query_nomination_process_donations', $donation_params))
504 || $compare_res($donation_query('query_nomination_process_gift_receipt', $gift_member_params), 1)) {
505
506 Logger::debug('fsfdrupalauth:'.$this->authId.': Past membership / donations meet threshold for nomination board process');
507 $attributes['nomination_process'] = ['true'];
508
509 } else {
510 Logger::debug('fsfdrupalauth:'.$this->authId.': Past membership / donations do not meet threshold for nomination board process');
511 $attributes['nomination_process'] = ['false'];
512 }
513
514 //
515 // discussion forum participation specific checks
516 //
517
518 $donation_params = ['start_date' => $discussion_process_start_date, 'end_date' => $discussion_process_end_date];
519 $old_member_params = $donation_params;
520 $adhoc_params = ['adhoc_access_group_id' => intval($this->discussion_process_adhoc_access_group_id)];
521 $adhoc_params_no = ['adhoc_access_group_id' => intval($this->discussion_process_adhoc_no_access_group_id)];
522
523 if ($this->discussion_process_active != 'true' ) {
524 Logger::debug('fsfdrupalauth:'.$this->authId.': Discussion board process checks not active');
525 $attributes['discussion_process'] = ['false'];
526
527 } elseif ($compare_res($donation_query('query_discussion_process_adhoc', $adhoc_params_no), 1)) {
528 Logger::debug('fsfdrupalauth:'.$this->authId.': Nominee not allowed to participate in board discussion process.');
529 $attributes['discussion_process'] = ['false'];
530
531 } elseif ($compare_res($donation_query('query_discussion_process_adhoc', $adhoc_params), 1)) {
532 Logger::debug('fsfdrupalauth:'.$this->authId.': In adhoc list of contacts for discussion board process');
533 $attributes['discussion_process'] = ['true'];
534
535 } elseif ($attributes['is_member'] != ['true']) {
536 Logger::debug('fsfdrupalauth :'.$this->authId.': Not a member, so not eligible for board nominee discussion process.');
537 $attributes['discussion_process'] = ['false'];
538
539 } elseif ($compare_res($old_membership_query('query_discussion_process_old_membership', $old_member_params), 1)
540 || $discussion_process_analyze_history($donation_query('query_discussion_process_donations', $donation_params))) {
541
542 Logger::debug('fsfdrupalauth:'.$this->authId.': Past membership / donations meet threshold for discussion board process');
543 $attributes['discussion_process'] = ['true'];
544
545 } else {
546 Logger::debug('fsfdrupalauth:'.$this->authId.': Past membership / donations do not meet threshold for discussion board process');
547 $attributes['discussion_process'] = ['false'];
548 }
549
550 //
551 // discussion forum moderator early access
552 //
553
554 $adhoc_params = ['adhoc_access_group_id' => intval($this->discussion_moderator_access_group_id)];
555
556 if ($compare_res($donation_query('query_discussion_process_adhoc', $adhoc_params), 1)) {
557 Logger::debug('fsfdrupalauth:'.$this->authId.': In adhoc list of moderators for board discussion forum');
558 $attributes['discussion_moderator'] = ['true'];
559
560 } else {
561 Logger::debug('fsfdrupalauth:'.$this->authId.': Not in adhoc list of moderators for board discussion forum');
562 $attributes['discussion_moderator'] = ['false'];
563 }
564
565 //
566 // aggregate attribute
567 //
568
569 $groups_list = '';
570 $first = true;
571 foreach ($attributes as $key => $value) {
572 if ($value == ['true']) {
573 if (!$first) {
574 $groups_list .= ', ';
575 }
576 $groups_list .= $key;
577 $first = false;
578 }
579 }
580
581 $attributes['groups_list'] = [$groups_list];
582 }
583
584 /**
585 * Attempt to log in using the given username and password.
586 *
587 * On a successful login, this function should return the users attributes. On failure,
588 * it should throw an exception. If the error was caused by the user entering the wrong
589 * username or password, a Error\Error('WRONGUSERPASS') should be thrown.
590 *
591 * Note that both the username and the password are UTF-8 encoded.
592 *
593 * @param string $username The username the user wrote.
594 * @param string $password The password the user wrote.
595 * @return array Associative array with the users attributes.
596 */
597 protected function login($username, $password)
598 {
599 assert(is_string($username));
600 assert(is_string($password));
601
602 //// keep this commented when it's not in use. it prints user passwords to the log file
603 //Logger::debug('fsfdrupalauth:'.$this->authId.': entered password: '.$password);
604
605
606 $user_data = $this->query_db('query_main', ['username' => $username]);
607
608
609 if (count($user_data) === 0) {
610 // No rows returned - invalid username
611 Logger::error('fsfdrupalauth:'.$this->authId.
612 ': No rows in result set. Probably wrong username.');
613 throw new Error\Error('WRONGUSERPASS');
614 }
615
616 /* Extract attributes. We allow the resultset to consist of multiple rows. Attributes
617 * which are present in more than one row will become multivalued. null values and
618 * duplicate values will be skipped. All values will be converted to strings.
619 */
620 $attributes = [];
621
622 // use the entered user name so we don't forcibly change it to all
623 // lower case. this is to preserve the behavior of the old cas server,
624 // and to remain compatible with our MW and Discourse sites that are
625 // case sensitive.
626 $attributes['name'][] = $username;
627
628 foreach ($user_data as $row) {
629 foreach ($row as $key => $value) {
630 if ($value === null) {
631 continue;
632 }
633
634 $value = (string) $value;
635
636 if (!array_key_exists($key, $attributes)) {
637 $attributes[$key] = [];
638 }
639
640 if (in_array($value, $attributes[$key], true)) {
641 // Value already exists in attribute
642 continue;
643 }
644
645 $attributes[$key][] = $value;
646 }
647 }
648
649 if (!$this->check_password($password, $attributes['pass'][0])) {
650 throw new Error\Error('WRONGUSERPASS');
651 }
652
653 unset($attributes['pass']);
654
655
656 $this->add_more_attributes($attributes, $username);
657
658
659 Logger::info('fsfdrupalauth:'.$this->authId.': Attributes: '.
660 implode(',', array_keys($attributes)));
661
662 return $attributes;
663 }
664 }