efad30f068143a44d6cac06df9f96e1e32a369a5
[fsfdrupalauth.git] / lib / Auth / Source / FSFDrupalAuth.php
1 <?php
2
3 declare(strict_types=1);
4
5 namespace SimpleSAML\Module\fsfdrupalauth\Auth\Source;
6
7 use Exception;
8 use PDO;
9 use PDOException;
10 use SimpleSAML\Assert\Assert;
11 use SimpleSAML\Error;
12 use SimpleSAML\Logger;
13
14 /**
15 * Extension of simple SQL authentication source
16 *
17 * @package SimpleSAMLphp
18 */
19
20 class FSFDrupalAuth extends \SimpleSAML\Module\core\Auth\UserPassBase
21 {
22 /**
23 * The DSN we should connect to.
24 */
25 private string $dsn;
26
27 /**
28 * The username we should connect to the database with.
29 */
30 private string $username;
31
32 /**
33 * The password we should connect to the database with.
34 */
35 private string $password;
36
37 /**
38 * The options that we should connect to the database with.
39 */
40 private string $options;
41
42 /**
43 * The query we should use to retrieve the attributes for the user.
44 *
45 * The username and password will be available as :username and :password.
46 */
47 private string $query_main;
48 private string $query_membership;
49 private string $query_staff;
50
51 private string $query_nomination_process_donations;
52 private string $query_nomination_process_gift_receipt;
53 private string $query_nomination_process_adhoc;
54
55 private string $query_discussion_process_old_membership;
56 private string $query_discussion_process_donations;
57 private string $query_discussion_process_adhoc;
58
59 /**
60 * SQL query parameters, or variables that help determine which attributes
61 * someone has
62 */
63 private string $fsf_org_id;
64 private string $gift_redeem_page_id;
65
66 private string $nomination_process_active;
67 private string $nomination_process_contrib_start_date;
68 private string $nomination_process_contrib_end_date;
69 private string $nomination_process_adhoc_access_group_id;
70 private string $membership_monthly_rate;
71 private string $student_membership_monthly_rate;
72
73 private string $discussion_process_active;
74 private string $discussion_process_contrib_start_date;
75 private string $discussion_process_contrib_end_date;
76 private string $discussion_process_adhoc_access_group_id;
77 private string $discussion_process_adhoc_no_access_group_id;
78 private string $discussion_process_donation_amount;
79
80 private string $discussion_moderator_access_group_id;
81
82 /**
83 * Constructor for this authentication source.
84 *
85 * @param array $info Information about this authentication source.
86 * @param array $config Configuration.
87 */
88 public function __construct(array $info, 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(): PDO
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(string $password, string $hash): boolean {
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(string $queryname, string $query_params): array
260 {
261 $db = $this->connect();
262
263 try {
264 $sth = $db->prepare($this->$queryname);
265 } catch (PDOException $e) {
266 throw new Exception('fsfdrupalauth:' . $this->authId .
267 ': - Failed to prepare queryname: ' . $queryname . ': ' . $e->getMessage());
268 }
269
270 try {
271 $sth->execute($query_params);
272 } catch (PDOException $e) {
273 throw new Exception('fsfdrupalauth:' . $this->authId .
274 ': - Failed to execute queryname: ' . $queryname . ': ' . $e->getMessage());
275 }
276
277 try {
278 $data = $sth->fetchAll(PDO::FETCH_ASSOC);
279 } catch (PDOException $e) {
280 throw new Exception('fsfdrupalauth:' . $this->authId .
281 ': - Failed to fetch result set: ' . $e->getMessage());
282 }
283
284 Logger::info('fsfdrupalauth:' . $this->authId . ': Got ' . count($data) .
285 ' rows from database');
286
287 return $data;
288 }
289
290 /**
291 * add more CAS attributes to user, such as is_staff and is_member
292 */
293 private function add_more_attributes(array &$attributes, string $username): void {
294
295 //
296 // query on staff
297 //
298
299 $staff_data = $this->query_db('query_staff', ['username' => $username, 'fsf_org_id' => $this->fsf_org_id]);
300
301 if (count($staff_data) === 0) {
302 // No rows returned - invalid username
303 Logger::debug('fsfdrupalauth:' . $this->authId .
304 ': No rows in result set. Probably not FSF staff.');
305 }
306
307 $attributes['is_fsf_staff'] = ['false'];
308
309 foreach ($staff_data as $row) {
310 foreach ($row as $key => $value) {
311
312 if ($value === null) {
313 continue;
314 }
315 $value = (string) $value;
316
317 if (strtolower($value) === strtolower($username)) {
318 // they are staff
319 $attributes['is_fsf_staff'] = ['true'];
320 break;
321 }
322 }
323 }
324
325 //
326 // query on membership
327 //
328
329 $membership_data = $this->query_db('query_membership', ['username' => $username]);
330
331 if (count($membership_data) === 0) {
332 // No rows returned - invalid username
333 Logger::debug('fsfdrupalauth:' . $this->authId .
334 ': No rows in result set. Probably no membership.');
335 }
336
337 $attributes['is_member'] = ['false'];
338 $attributes['was_member'] = ['false'];
339
340 foreach ($membership_data as $row) {
341 foreach ($row as $key => $value) {
342 if ($value === null) {
343 continue;
344 }
345 $value = (string) $value;
346
347 if ($value === '1' || $value === '2' || $value === '3') {
348 $attributes['is_member'] = ['true'];
349 $attributes['was_member'] = ['true'];
350 } elseif ($value === '4') {
351 $attributes['was_member'] = ['true'];
352 }
353 }
354 }
355
356 //
357 // helper functions for access to board nomination / discussion process
358 //
359
360 /**
361 * @param string $query_name Name of query in authsources
362 * @param array $extra_params Associative array of parameters to include in query
363 */
364 $donation_query = function (string $query_name, array $extra_params): array
365 use (string $username) {
366
367 $parameters = ['username' => $username];
368
369 foreach ($extra_params as $key => $value) {
370 $parameters[$key] = $value;
371 }
372
373 return $this->query_db($query_name, $parameters);
374 };
375
376 $old_membership_query = $donation_query;
377
378 $compare_res = function (array $result, int $amount): void {
379 foreach ($result[0] as $key => $value) {
380 if (intval($value) >= $amount) {
381 return true;
382 }
383 }
384 return false;
385 };
386
387 // set dates here, used by helper functions below
388 $nomination_process_start_date = $this->nomination_process_contrib_start_date;
389 $nomination_process_end_date = $this->nomination_process_contrib_end_date;
390 $discussion_process_start_date = $this->discussion_process_contrib_start_date;
391 $discussion_process_end_date = $this->discussion_process_contrib_end_date;
392
393
394 // looks for memberships / comparable donations in time window. also
395 // looks for a membership or donation (included as a param) that
396 // occurred up to a year before, and that would have carried over into
397 // the time window with a single donation. this approximates whether
398 // the person was, or would have been, a member during the configured
399 // time window.
400 $nomination_process_analyze_history = function (array $selective_donations_history): boolean
401 use (string $nomination_process_start_date, string $nomination_process_end_date) {
402
403 $eligible = false;
404
405 Logger::debug('fsfdrupalauth:' . $this->authId .
406 ': start date: ' . $nomination_process_start_date . " end date: " . $nomination_process_end_date);
407
408 $start_date_obj = new \DateTime($nomination_process_start_date);
409 $end_date_obj = new \DateTime($nomination_process_end_date);
410
411 foreach ($selective_donations_history as $row) {
412
413 $amount = intval($row['amount']);
414 $member_type_id = $row['member_type_id'];
415 $receive_date_obj = new \DateTime($row['receive_date']);
416
417 if ($amount < 5) {
418 continue;
419
420 } elseif ($receive_date_obj >= $start_date_obj and $receive_date_obj <= $end_date_obj) {
421 return true;
422
423 } elseif ($receive_date_obj < $start_date_obj) {
424 switch ($member_type_id) {
425 case '1':
426 case '2':
427 $rate = intval($this->student_membership_monthly_rate);
428 break;
429 case '8':
430 case '9':
431 case null:
432 default:
433 $rate = intval($this->membership_monthly_rate);
434 break;
435 }
436 $membership_end_date_obj = new \DateTime($row['receive_date']);
437 $membership_end_date_obj->add(new \DateInterval("P" . ceil($amount / $rate) . "M"));
438
439 if ($membership_end_date_obj >= $start_date_obj) {
440 return true;
441 }
442 }
443 }
444 return false;
445 };
446
447 $discussion_process_analyze_history = function (array $selective_donations_history): boolean
448 use (string $discussion_process_start_date, string $discussion_process_end_date) {
449
450 $eligible = false;
451 $total = 0;
452
453 Logger::debug('fsfdrupalauth:' . $this->authId .
454 ': start date: ' . $discussion_process_start_date . " end date: " . $discussion_process_end_date);
455
456 $start_date_obj = new \DateTime($discussion_process_start_date);
457 $end_date_obj = new \DateTime($discussion_process_end_date);
458
459 foreach ($selective_donations_history as $row) {
460
461 $amount = intval($row['amount']);
462 $member_type_id = $row['member_type_id'];
463 $receive_date_obj = new \DateTime($row['receive_date']);
464
465 if (($receive_date_obj > $start_date_obj) && ($receive_date_obj < $end_date_obj)) {
466 $total += $amount;
467 }
468 }
469
470 Logger::debug('fsfdrupalauth:' . $this->authId .
471 ': total amount: $' . $total);
472
473 if ($total >= $this->discussion_process_donation_amount) {
474 return true;
475 } else {
476 return false;
477 }
478 };
479
480 //
481 // nomination form participation specific checks
482 //
483
484 $donation_params = ['start_date' => $nomination_process_start_date, 'end_date' => $nomination_process_end_date];
485 $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)];
486 $adhoc_params = ['adhoc_access_group_id' => intval($this->nomination_process_adhoc_access_group_id)];
487
488 if ($this->nomination_process_active != 'true' ) {
489 Logger::debug('fsfdrupalauth:' . $this->authId . ': Nomination board process checks not active');
490 $attributes['nomination_process'] = ['false'];
491
492 } elseif ($compare_res($donation_query('query_nomination_process_adhoc', $adhoc_params), 1)) {
493 Logger::debug('fsfdrupalauth:' . $this->authId . ': In adhoc list of contacts for nomination board process');
494 $attributes['nomination_process'] = ['true'];
495
496 } elseif ($attributes['is_member'] != ['true']) {
497 Logger::debug('fsfdrupalauth:' . $this->authId . ': Not a current member for nomination board process');
498 $attributes['nomination_process'] = ['false'];
499
500 } elseif ($nomination_process_analyze_history($donation_query('query_nomination_process_donations', $donation_params))
501 || $compare_res($donation_query('query_nomination_process_gift_receipt', $gift_member_params), 1)) {
502
503 Logger::debug('fsfdrupalauth:' . $this->authId . ': Past membership / donations meet threshold for nomination board process');
504 $attributes['nomination_process'] = ['true'];
505
506 } else {
507 Logger::debug('fsfdrupalauth:' . $this->authId . ': Past membership / donations do not meet threshold for nomination board process');
508 $attributes['nomination_process'] = ['false'];
509 }
510
511 //
512 // discussion forum participation specific checks
513 //
514
515 $donation_params = ['start_date' => $discussion_process_start_date, 'end_date' => $discussion_process_end_date];
516 $old_member_params = $donation_params;
517 $adhoc_params = ['adhoc_access_group_id' => intval($this->discussion_process_adhoc_access_group_id)];
518 $adhoc_params_no = ['adhoc_access_group_id' => intval($this->discussion_process_adhoc_no_access_group_id)];
519
520 if ($this->discussion_process_active != 'true' ) {
521 Logger::debug('fsfdrupalauth:' . $this->authId . ': Discussion board process checks not active');
522 $attributes['discussion_process'] = ['false'];
523
524 } elseif ($compare_res($donation_query('query_discussion_process_adhoc', $adhoc_params_no), 1)) {
525 Logger::debug('fsfdrupalauth:' . $this->authId . ': Nominee not allowed to participate in board discussion process.');
526 $attributes['discussion_process'] = ['false'];
527
528 } elseif ($compare_res($donation_query('query_discussion_process_adhoc', $adhoc_params), 1)) {
529 Logger::debug('fsfdrupalauth:' . $this->authId . ': In adhoc list of contacts for discussion board process');
530 $attributes['discussion_process'] = ['true'];
531
532 } elseif ($attributes['is_member'] != ['true']) {
533 Logger::debug('fsfdrupalauth :' . $this->authId . ': Not a member, so not eligible for board nominee discussion process.');
534 $attributes['discussion_process'] = ['false'];
535
536 } elseif ($compare_res($old_membership_query('query_discussion_process_old_membership', $old_member_params), 1)
537 || $discussion_process_analyze_history($donation_query('query_discussion_process_donations', $donation_params))) {
538
539 Logger::debug('fsfdrupalauth:' . $this->authId . ': Past membership / donations meet threshold for discussion board process');
540 $attributes['discussion_process'] = ['true'];
541
542 } else {
543 Logger::debug('fsfdrupalauth:' . $this->authId . ': Past membership / donations do not meet threshold for discussion board process');
544 $attributes['discussion_process'] = ['false'];
545 }
546
547 //
548 // discussion forum moderator early access
549 //
550
551 $adhoc_params = ['adhoc_access_group_id' => intval($this->discussion_moderator_access_group_id)];
552
553 if ($compare_res($donation_query('query_discussion_process_adhoc', $adhoc_params), 1)) {
554 Logger::debug('fsfdrupalauth:' . $this->authId . ': In adhoc list of moderators for board discussion forum');
555 $attributes['discussion_moderator'] = ['true'];
556
557 } else {
558 Logger::debug('fsfdrupalauth:' . $this->authId . ': Not in adhoc list of moderators for board discussion forum');
559 $attributes['discussion_moderator'] = ['false'];
560 }
561
562 //
563 // aggregate attribute
564 //
565
566 $groups_list = '';
567 $first = true;
568 foreach ($attributes as $key => $value) {
569 if ($value == ['true']) {
570 if (!$first) {
571 $groups_list .= ', ';
572 }
573 $groups_list .= $key;
574 $first = false;
575 }
576 }
577
578 $attributes['groups_list'] = [$groups_list];
579 }
580
581 /**
582 * Attempt to log in using the given username and password.
583 *
584 * On a successful login, this function should return the users attributes. On failure,
585 * it should throw an exception. If the error was caused by the user entering the wrong
586 * username or password, a Error\Error('WRONGUSERPASS') should be thrown.
587 *
588 * Note that both the username and the password are UTF-8 encoded.
589 *
590 * @param string $username The username the user wrote.
591 * @param string $password The password the user wrote.
592 * @return array Associative array with the users attributes.
593 */
594 protected function login(string $username, string $password): array
595 {
596
597 //// keep this commented when it's not in use. it prints user passwords to the log file
598 //Logger::debug('fsfdrupalauth:' . $this->authId . ': entered password: ' . $password);
599
600
601 $user_data = $this->query_db('query_main', ['username' => $username]);
602
603
604 if (count($user_data) === 0) {
605 // No rows returned - invalid username
606 Logger::error('fsfdrupalauth:' . $this->authId .
607 ': No rows in result set. Probably wrong username.');
608 throw new Error\Error('WRONGUSERPASS');
609 }
610
611 /* Extract attributes. We allow the resultset to consist of multiple rows. Attributes
612 * which are present in more than one row will become multivalued. null values and
613 * duplicate values will be skipped. All values will be converted to strings.
614 */
615 $attributes = [];
616
617 // use the entered user name so we don't forcibly change it to all
618 // lower case. this is to preserve the behavior of the old cas server,
619 // and to remain compatible with our MW and Discourse sites that are
620 // case sensitive.
621 $attributes['name'][] = $username;
622
623 foreach ($user_data as $row) {
624 foreach ($row as $key => $value) {
625 if ($value === null) {
626 continue;
627 }
628
629 $value = (string) $value;
630
631 if (!array_key_exists($key, $attributes)) {
632 $attributes[$key] = [];
633 }
634
635 if (in_array($value, $attributes[$key], true)) {
636 // Value already exists in attribute
637 continue;
638 }
639
640 $attributes[$key][] = $value;
641 }
642 }
643
644 if (!$this->check_password($password, $attributes['pass'][0])) {
645 throw new Error\Error('WRONGUSERPASS');
646 }
647
648 unset($attributes['pass']);
649
650
651 $this->add_more_attributes($attributes, $username);
652
653
654 Logger::info('fsfdrupalauth:' . $this->authId . ': Attributes: ' .
655 implode(',', array_keys($attributes)));
656
657 return $attributes;
658 }
659 }