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