allow staff to access board forum
[fsfdrupalauth.git] / lib / Auth / Source / FSFDrupalAuth.php
CommitLineData
395539d7
AE
1<?php
2
3namespace SimpleSAML\Module\fsfdrupalauth\Auth\Source;
4
cf44092c
AE
5use Exception;
6use PDO;
7use PDOException;
8use SimpleSAML\Error;
9use SimpleSAML\Logger;
10
395539d7
AE
11/**
12 * Extension of simple SQL authentication source
13 *
14 * @package SimpleSAMLphp
15 */
16
17class 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;
794d92ca 47
78e03f98 48 private $query_nomination_process_donations;
29854532 49 private $query_nomination_process_gift_receipt;
3fa64def 50 private $query_nomination_process_adhoc;
395539d7 51
1ae1ff15 52 private $query_discussion_process_old_membership;
794d92ca 53 private $query_discussion_process_donations;
794d92ca
AE
54 private $query_discussion_process_adhoc;
55
c0d116a9 56 /**
e12acfe1
AE
57 * SQL query parameters, or variables that help determine which attributes
58 * someone has
c0d116a9 59 */
e12acfe1 60 private $fsf_org_id;
e9db6ecd
AE
61 private $gift_redeem_page_id;
62
2d61361e 63 private $nomination_process_active;
78e03f98
AE
64 private $nomination_process_contrib_start_date;
65 private $nomination_process_contrib_end_date;
e9db6ecd 66 private $nomination_process_adhoc_access_group_id;
f38130e1
AE
67 private $membership_monthly_rate;
68 private $student_membership_monthly_rate;
c0d116a9 69
794d92ca
AE
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;
79ae195e 75 private $discussion_process_donation_amount;
794d92ca 76
395539d7
AE
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.
e9db6ecd
AE
92 foreach (['dsn',
93 'username',
94 'password',
95
96 'query_main',
97 'query_membership',
98 'query_staff',
99
e12acfe1 100 'query_nomination_process_donations',
3fa64def 101 'query_nomination_process_gift_receipt',
e9db6ecd
AE
102 'query_nomination_process_adhoc',
103
104 'fsf_org_id',
105 'gift_redeem_page_id',
106
2d61361e 107 'nomination_process_active',
e12acfe1 108 'nomination_process_contrib_start_date',
e9db6ecd
AE
109 'nomination_process_contrib_end_date',
110 'nomination_process_adhoc_access_group_id',
111 'membership_monthly_rate',
794d92ca
AE
112 'student_membership_monthly_rate',
113
1ae1ff15 114 'query_discussion_process_old_membership',
794d92ca 115 'query_discussion_process_donations',
794d92ca
AE
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',
79ae195e
AE
122 'discussion_process_adhoc_no_access_group_id',
123 'discussion_process_donation_amount',]
3fa64def 124 as $param) {
e12acfe1 125
395539d7 126 if (!array_key_exists($param, $config)) {
cf44092c 127 throw new Exception('Missing required attribute \''.$param.
395539d7
AE
128 '\' for authentication source '.$this->authId);
129 }
130
131 if (!is_string($config[$param])) {
cf44092c 132 throw new Exception('Expected parameter \''.$param.
395539d7
AE
133 '\' for authentication source '.$this->authId.
134 ' to be a string. Instead it was: '.
135 var_export($config[$param], true));
136 }
e12acfe1
AE
137
138 $this->$param = $config[$param];
395539d7
AE
139 }
140
395539d7
AE
141 if (isset($config['options'])) {
142 $this->options = $config['options'];
143 }
144 }
145
146
147 /**
148 * Create a database connection.
149 *
f58b2b6b 150 * @return PDO The database connection.
395539d7
AE
151 */
152 private function connect()
153 {
154 try {
cf44092c
AE
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());
395539d7
AE
162 }
163
cf44092c 164 $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
395539d7
AE
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(
59f90414
AE
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
395539d7
AE
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
cf44092c 227 //Logger::debug('fsfdrupalauth:'.$this->authId.': authenticator stdout: '.$result);
3fa64def 228
e57e2393 229 $errors_found_yet = false;
395539d7 230 if ($errors != "") {
cf44092c 231 Logger::error('fsfdrupalauth:'.$this->authId.': authenticator stderr: '.$errors);
e57e2393 232 $errors_found_yet = true;
395539d7 233 }
3fa64def 234
395539d7 235 if ($return_value != 0) {
cf44092c 236 Logger::error('fsfdrupalauth:'.$this->authId.': authenticator non-zero return code: '.$return_value);
e57e2393 237 $errors_found_yet = true;
395539d7 238 }
3fa64def 239
6921b9d4 240 return (!$errors_found_yet && is_string($result) && rtrim($result) == "true");
395539d7
AE
241
242 } else {
243
cf44092c 244 Logger::error('fsfdrupalauth:'.$this->authId.': unable to launch authenticator');
395539d7
AE
245
246 return false;
247 }
248 }
249
250 /**
251 *
252 * query the database with arbitrary queries that only require a user name.
253 *
254 */
2e644466 255 private function query_db($queryname, $query_params)
395539d7
AE
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);
cf44092c
AE
264 } catch (PDOException $e) {
265 throw new Exception('fsfdrupalauth:'.$this->authId.
395539d7
AE
266 ': - Failed to prepare queryname: '.$queryname.': '.$e->getMessage());
267 }
268
269 try {
2e644466 270 $sth->execute($query_params);
cf44092c
AE
271 } catch (PDOException $e) {
272 throw new Exception('fsfdrupalauth:'.$this->authId.
395539d7
AE
273 ': - Failed to execute queryname: '.$queryname.': '.$e->getMessage());
274 }
275
276 try {
f58b2b6b 277 $data = $sth->fetchAll(PDO::FETCH_ASSOC);
cf44092c
AE
278 } catch (PDOException $e) {
279 throw new Exception('fsfdrupalauth:'.$this->authId.
395539d7
AE
280 ': - Failed to fetch result set: '.$e->getMessage());
281 }
282
cf44092c 283 Logger::info('fsfdrupalauth:'.$this->authId.': Got '.count($data).
395539d7
AE
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
fd2b4a48
AE
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
f33432a6
AE
324 //
325 // query on membership
326 //
395539d7 327
2e644466 328 $membership_data = $this->query_db('query_membership', ['username' => $username]);
395539d7
AE
329
330 if (count($membership_data) === 0) {
331 // No rows returned - invalid username
cf44092c 332 Logger::debug('fsfdrupalauth:'.$this->authId.
395539d7
AE
333 ': No rows in result set. Probably no membership.');
334 }
335
f33432a6 336 $attributes['is_member'] = ['false'];
395539d7
AE
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
f33432a6 346 if ($value === '1' || $value === '2' || $value === '3') {
395539d7
AE
347 $attributes['is_member'] = ['true'];
348 $attributes['was_member'] = ['true'];
f33432a6 349 } elseif ($value === '4') {
395539d7 350 $attributes['was_member'] = ['true'];
f33432a6 351 }
395539d7
AE
352 }
353 }
354
c0d116a9 355 //
0782e0aa 356 // helper functions for access to board nomination / discussion process
c0d116a9
AE
357 //
358
29854532
AE
359 /**
360 * @param string $query_name Name of query in authsources
f38130e1 361 * @param array $extra_params Associative array of parameters to include in query
29854532 362 */
f38130e1
AE
363 $donation_query = function ($query_name, $extra_params)
364 use ($username) {
29854532 365
3fa64def 366 $parameters = ['username' => $username];
29854532 367
3fa64def
AE
368 foreach ($extra_params as $key => $value) {
369 $parameters[$key] = $value;
29854532 370 }
78e03f98 371
f38130e1
AE
372 return $this->query_db($query_name, $parameters);
373 };
78e03f98 374
1ae1ff15
AE
375 $old_membership_query = $donation_query;
376
f38130e1 377 $compare_res = function ($result, $amount) {
29854532 378 foreach ($result[0] as $key => $value) {
f38130e1
AE
379 if (intval($value) >= $amount) {
380 return true;
381 }
382 }
383 return false;
384 };
385
32c7abac
AE
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
f38130e1
AE
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.
0782e0aa 399 $nomination_process_analyze_history = function ($selective_donations_history)
32c7abac 400 use ($nomination_process_start_date, $nomination_process_end_date) {
f38130e1
AE
401
402 $eligible = false;
403
21c240cb
AE
404 Logger::debug('fsfdrupalauth:'.$this->authId.
405 ': start date: '.$nomination_process_start_date. " end date: ".$nomination_process_end_date);
406
32c7abac
AE
407 $start_date_obj = new \DateTime($nomination_process_start_date);
408 $end_date_obj = new \DateTime($nomination_process_end_date);
f38130e1
AE
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 }
78e03f98 442 }
78e03f98
AE
443 return false;
444 };
445
794d92ca 446 $discussion_process_analyze_history = function ($selective_donations_history)
32c7abac 447 use ($discussion_process_start_date, $discussion_process_end_date) {
794d92ca
AE
448
449 $eligible = false;
450 $total = 0;
451
21c240cb
AE
452 Logger::debug('fsfdrupalauth:'.$this->authId.
453 ': start date: '.$discussion_process_start_date. " end date: ".$discussion_process_end_date);
454
32c7abac
AE
455 $start_date_obj = new \DateTime($discussion_process_start_date);
456 $end_date_obj = new \DateTime($discussion_process_end_date);
794d92ca
AE
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
32c7abac 464 if (($receive_date_obj > $start_date_obj) && ($receive_date_obj < $end_date_obj)) {
794d92ca
AE
465 $total += $amount;
466 }
467 }
468
21c240cb
AE
469 Logger::debug('fsfdrupalauth:'.$this->authId.
470 ': total amount: $'.$total);
471
79ae195e 472 if ($total >= $this->discussion_process_donation_amount) {
794d92ca
AE
473 return true;
474 } else {
475 return false;
476 }
477 };
478
0782e0aa
AE
479 //
480 // nomination form participation specific checks
481 //
482
32c7abac
AE
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)];
e9db6ecd 485 $adhoc_params = ['adhoc_access_group_id' => intval($this->nomination_process_adhoc_access_group_id)];
3fa64def 486
16858322
AE
487 if ($this->nomination_process_active != 'true' ) {
488 $attributes['nomination_process'] = ['false'];
0782e0aa 489
16858322
AE
490 } elseif ($compare_res($donation_query('query_nomination_process_adhoc', $adhoc_params), 1)) {
491 $attributes['nomination_process'] = ['true'];
492
493 } elseif ($attributes['is_member'] != ['true']) {
494 Logger::debug('fsfdrupalauth:'.$this->authId.
495 ': Not a member / comparable donor during window for board process.');
496 $attributes['nomination_process'] = ['false'];
497
498 } elseif ($nomination_process_analyze_history($donation_query('query_nomination_process_donations', $donation_params))
499 || $compare_res($donation_query('query_nomination_process_gift_receipt', $gift_member_params), 1)) {
500
501 $attributes['nomination_process'] = ['true'];
29854532 502
78e03f98 503 } else {
16858322
AE
504 Logger::debug('fsfdrupalauth:'.$this->authId.
505 ': Not a member / comparable donor during window for board process.');
78e03f98
AE
506 $attributes['nomination_process'] = ['false'];
507 }
c0d116a9 508
794d92ca
AE
509 //
510 // discussion form participation specific checks
511 //
512
32c7abac 513 $donation_params = ['start_date' => $discussion_process_start_date, 'end_date' => $discussion_process_end_date];
1ae1ff15 514 $old_member_params = $donation_params;
794d92ca
AE
515 $adhoc_params = ['adhoc_access_group_id' => intval($this->discussion_process_adhoc_access_group_id)];
516 $adhoc_params_no = ['adhoc_access_group_id' => intval($this->discussion_process_adhoc_no_access_group_id)];
517
16858322
AE
518 if ($this->discussion_process_active != 'true' ) {
519 $attributes['discussion_process'] = ['false'];
794d92ca 520
16858322
AE
521 } elseif ($compare_res($donation_query('query_discussion_process_adhoc', $adhoc_params_no), 1)) {
522 Logger::debug('fsfdrupalauth:'.$this->authId.
523 ': Nominee not eligible for board nominee discussion process.');
524 $attributes['discussion_process'] = ['false'];
794d92ca 525
16858322
AE
526 } elseif ($compare_res($donation_query('query_discussion_process_adhoc', $adhoc_params), 1)) {
527 $attributes['discussion_process'] = ['true'];
794d92ca 528
fd2b4a48
AE
529 } elseif ($attributes['is_fsf_staff'] == ['true']) {
530 $attributes['discussion_process'] = ['true'];
531
16858322
AE
532 } elseif ($attributes['is_member'] != ['true']) {
533 Logger::debug('fsfdrupalauth:'.$this->authId.
534 ': Not eligible for board nominee discussion process.');
535 $attributes['discussion_process'] = ['false'];
536
1ae1ff15 537 } elseif ($compare_res($old_membership_query('query_discussion_process_old_membership', $old_member_params), 1)
5ba93058 538 || $discussion_process_analyze_history($donation_query('query_discussion_process_donations', $donation_params))) {
16858322
AE
539
540 $attributes['discussion_process'] = ['true'];
794d92ca 541
794d92ca 542 } else {
16858322
AE
543 Logger::debug('fsfdrupalauth:'.$this->authId.
544 ': Not eligible for board nominee discussion process.');
794d92ca
AE
545 $attributes['discussion_process'] = ['false'];
546 }
547
429471b4
AE
548 //
549 // aggregate attribute
550 //
551
552 $groups_list = '';
553 $first = true;
554 foreach ($attributes as $key => $value) {
555 if ($value == ['true']) {
556 if (!$first) {
557 $groups_list .= ', ';
558 }
559 $groups_list .= $key;
560 $first = false;
561 }
562 }
563
564 $attributes['groups_list'] = [$groups_list];
395539d7
AE
565 }
566
567 /**
568 * Attempt to log in using the given username and password.
569 *
570 * On a successful login, this function should return the users attributes. On failure,
571 * it should throw an exception. If the error was caused by the user entering the wrong
cf44092c 572 * username or password, a Error\Error('WRONGUSERPASS') should be thrown.
395539d7
AE
573 *
574 * Note that both the username and the password are UTF-8 encoded.
575 *
576 * @param string $username The username the user wrote.
577 * @param string $password The password the user wrote.
578 * @return array Associative array with the users attributes.
579 */
580 protected function login($username, $password)
581 {
582 assert(is_string($username));
583 assert(is_string($password));
584
585 //// keep this commented when it's not in use. it prints user passwords to the log file
cf44092c 586 //Logger::debug('fsfdrupalauth:'.$this->authId.': entered password: '.$password);
395539d7
AE
587
588
2e644466 589 $user_data = $this->query_db('query_main', ['username' => $username]);
395539d7
AE
590
591
592 if (count($user_data) === 0) {
593 // No rows returned - invalid username
cf44092c 594 Logger::error('fsfdrupalauth:'.$this->authId.
395539d7 595 ': No rows in result set. Probably wrong username.');
cf44092c 596 throw new Error\Error('WRONGUSERPASS');
395539d7
AE
597 }
598
599 /* Extract attributes. We allow the resultset to consist of multiple rows. Attributes
600 * which are present in more than one row will become multivalued. null values and
601 * duplicate values will be skipped. All values will be converted to strings.
602 */
603 $attributes = [];
604
f33432a6
AE
605 // use the entered user name so we don't forcibly change it to all
606 // lower case. this is to preserve the behavior of the old cas server,
607 // and to remain compatible with our MW and Discourse sites that are
608 // case sensitive.
609 $attributes['name'][] = $username;
395539d7
AE
610
611 foreach ($user_data as $row) {
612 foreach ($row as $key => $value) {
613 if ($value === null) {
614 continue;
615 }
616
617 $value = (string) $value;
618
619 if (!array_key_exists($key, $attributes)) {
620 $attributes[$key] = [];
621 }
622
623 if (in_array($value, $attributes[$key], true)) {
624 // Value already exists in attribute
625 continue;
626 }
627
628 $attributes[$key][] = $value;
629 }
630 }
631
632 if (!$this->check_password($password, $attributes['pass'][0])) {
cf44092c 633 throw new Error\Error('WRONGUSERPASS');
395539d7
AE
634 }
635
636 unset($attributes['pass']);
637
638
639 $this->add_more_attributes($attributes, $username);
640
641
cf44092c 642 Logger::info('fsfdrupalauth:'.$this->authId.': Attributes: '.
395539d7
AE
643 implode(',', array_keys($attributes)));
644
645 return $attributes;
646 }
647}