3 declare(strict_types
=1);
5 namespace SimpleSAML\Module\fsfdrupalauth\Auth\Source
;
10 use SimpleSAML\Assert\Assert
;
12 use SimpleSAML\Logger
;
15 * Extension of simple SQL authentication source
17 * @package SimpleSAMLphp
20 class FSFDrupalAuth
extends \SimpleSAML\Module\core\Auth\UserPassBase
23 * The DSN we should connect to.
28 * The username we should connect to the database with.
30 private string $username;
33 * The password we should connect to the database with.
35 private string $password;
38 * The options that we should connect to the database with.
40 private string $options;
43 * The query we should use to retrieve the attributes for the user.
45 * The username and password will be available as :username and :password.
47 private string $query_main;
48 private string $query_membership;
49 private string $query_staff;
51 private string $query_nomination_process_donations;
52 private string $query_nomination_process_gift_receipt;
53 private string $query_nomination_process_adhoc;
55 private string $query_discussion_process_old_membership;
56 private string $query_discussion_process_donations;
57 private string $query_discussion_process_adhoc;
60 * SQL query parameters, or variables that help determine which attributes
63 private string $fsf_org_id;
64 private string $gift_redeem_page_id;
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;
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;
80 private string $discussion_moderator_access_group_id;
83 * Constructor for this authentication source.
85 * @param array $info Information about this authentication source.
86 * @param array $config Configuration.
88 public function __construct(array $info, array $config)
90 // Call the parent constructor first, as required by the interface
91 parent
::__construct($info, $config);
93 // Make sure that all required parameters are present.
102 'query_nomination_process_donations',
103 'query_nomination_process_gift_receipt',
104 'query_nomination_process_adhoc',
107 'gift_redeem_page_id',
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',
116 'query_discussion_process_old_membership',
117 'query_discussion_process_donations',
118 'query_discussion_process_adhoc',
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',
127 'discussion_moderator_access_group_id',]
130 if (!array_key_exists($param, $config)) {
131 throw new Exception('Missing required attribute \'' . $param .
132 '\' for authentication source ' . $this->authId
);
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));
142 $this->$param = $config[$param];
145 if (isset($config['options'])) {
146 $this->options
= $config['options'];
152 * Create a database connection.
154 * @return PDO The database connection.
156 private function connect(): PDO
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
);
164 throw new Exception('fsfdrupalauth:' . $this->authId
. ': - Failed to connect to \'' .
165 $obfuscated_dsn . '\': ' . $e->getMessage());
168 $db->setAttribute(PDO
::ATTR_ERRMODE
, PDO
::ERRMODE_EXCEPTION
);
170 $driver = explode(':', $this->dsn
, 2);
171 $driver = strtolower($driver[0]);
173 // Driver specific initialization
177 $db->exec("SET NAMES 'utf8mb4'");
181 $db->exec("SET NAMES 'UTF8'");
189 * Check the password against a Drupal hash
192 private function check_password(string $password, string $hash): boolean
{
195 // The reason for running a separate process is so that the PHP global
196 // env doesn't get clobbered by include / require.
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
207 $cwd = "../modules/fsfdrupalauth/extlib";
208 //$env = array('some_option' => 'aeiou');
211 $process = proc_open('php drupal-pw-check.php', $descriptorspec, $pipes, $cwd, $env);
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
218 fwrite($pipes[0], json_encode([$password, $hash]));
221 $result = stream_get_contents($pipes[1]);
224 $errors = stream_get_contents($pipes[2]);
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);
231 //Logger::debug('fsfdrupalauth:' . $this->authId . ': authenticator stdout: ' . $result);
233 $errors_found_yet = false;
235 Logger
::error('fsfdrupalauth:' . $this->authId
.': authenticator stderr: ' . $errors);
236 $errors_found_yet = true;
239 if ($return_value != 0) {
240 Logger
::error('fsfdrupalauth:' . $this->authId
. ': authenticator non-zero return code: ' . $return_value);
241 $errors_found_yet = true;
244 return (!$errors_found_yet && is_string($result) && rtrim($result) == "true");
248 Logger
::error('fsfdrupalauth:' . $this->authId
. ': unable to launch authenticator');
256 * query the database with arbitrary queries that only require a user name.
259 private function query_db(string $queryname, string $query_params): array
261 $db = $this->connect();
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());
271 $sth->execute($query_params);
272 } catch (PDOException
$e) {
273 throw new Exception('fsfdrupalauth:' . $this->authId
.
274 ': - Failed to execute queryname: ' . $queryname . ': ' . $e->getMessage());
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());
284 Logger
::info('fsfdrupalauth:' . $this->authId
. ': Got ' . count($data) .
285 ' rows from database');
291 * add more CAS attributes to user, such as is_staff and is_member
293 private function add_more_attributes(array &$attributes, string $username): void
{
299 $staff_data = $this->query_db('query_staff', ['username' => $username, 'fsf_org_id' => $this->fsf_org_id
]);
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.');
307 $attributes['is_fsf_staff'] = ['false'];
309 foreach ($staff_data as $row) {
310 foreach ($row as $key => $value) {
312 if ($value === null) {
315 $value = (string) $value;
317 if (strtolower($value) === strtolower($username)) {
319 $attributes['is_fsf_staff'] = ['true'];
326 // query on membership
329 $membership_data = $this->query_db('query_membership', ['username' => $username]);
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.');
337 $attributes['is_member'] = ['false'];
338 $attributes['was_member'] = ['false'];
340 foreach ($membership_data as $row) {
341 foreach ($row as $key => $value) {
342 if ($value === null) {
345 $value = (string) $value;
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'];
357 // helper functions for access to board nomination / discussion process
361 * @param string $query_name Name of query in authsources
362 * @param array $extra_params Associative array of parameters to include in query
364 $donation_query = function (string $query_name, array $extra_params): array
365 use (string $username) {
367 $parameters = ['username' => $username];
369 foreach ($extra_params as $key => $value) {
370 $parameters[$key] = $value;
373 return $this->query_db($query_name, $parameters);
376 $old_membership_query = $donation_query;
378 $compare_res = function (array $result, int $amount): void
{
379 foreach ($result[0] as $key => $value) {
380 if (intval($value) >= $amount) {
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
;
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
400 $nomination_process_analyze_history = function (array $selective_donations_history): boolean
401 use (string $nomination_process_start_date, string $nomination_process_end_date) {
405 Logger
::debug('fsfdrupalauth:' . $this->authId
.
406 ': start date: ' . $nomination_process_start_date . " end date: " . $nomination_process_end_date);
408 $start_date_obj = new \
DateTime($nomination_process_start_date);
409 $end_date_obj = new \
DateTime($nomination_process_end_date);
411 foreach ($selective_donations_history as $row) {
413 $amount = intval($row['amount']);
414 $member_type_id = $row['member_type_id'];
415 $receive_date_obj = new \
DateTime($row['receive_date']);
420 } elseif ($receive_date_obj >= $start_date_obj and $receive_date_obj <= $end_date_obj) {
423 } elseif ($receive_date_obj < $start_date_obj) {
424 switch ($member_type_id) {
427 $rate = intval($this->student_membership_monthly_rate
);
433 $rate = intval($this->membership_monthly_rate
);
436 $membership_end_date_obj = new \
DateTime($row['receive_date']);
437 $membership_end_date_obj->add(new \
DateInterval("P" . ceil($amount / $rate) . "M"));
439 if ($membership_end_date_obj >= $start_date_obj) {
447 $discussion_process_analyze_history = function (array $selective_donations_history): boolean
448 use (string $discussion_process_start_date, string $discussion_process_end_date) {
453 Logger
::debug('fsfdrupalauth:' . $this->authId
.
454 ': start date: ' . $discussion_process_start_date . " end date: " . $discussion_process_end_date);
456 $start_date_obj = new \
DateTime($discussion_process_start_date);
457 $end_date_obj = new \
DateTime($discussion_process_end_date);
459 foreach ($selective_donations_history as $row) {
461 $amount = intval($row['amount']);
462 $member_type_id = $row['member_type_id'];
463 $receive_date_obj = new \
DateTime($row['receive_date']);
465 if (($receive_date_obj > $start_date_obj) && ($receive_date_obj < $end_date_obj)) {
470 Logger
::debug('fsfdrupalauth:' . $this->authId
.
471 ': total amount: $' . $total);
473 if ($total >= $this->discussion_process_donation_amount
) {
481 // nomination form participation specific checks
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
)];
488 if ($this->nomination_process_active
!= 'true' ) {
489 Logger
::debug('fsfdrupalauth:' . $this->authId
. ': Nomination board process checks not active');
490 $attributes['nomination_process'] = ['false'];
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'];
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'];
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)) {
503 Logger
::debug('fsfdrupalauth:' . $this->authId
. ': Past membership / donations meet threshold for nomination board process');
504 $attributes['nomination_process'] = ['true'];
507 Logger
::debug('fsfdrupalauth:' . $this->authId
. ': Past membership / donations do not meet threshold for nomination board process');
508 $attributes['nomination_process'] = ['false'];
512 // discussion forum participation specific checks
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
)];
520 if ($this->discussion_process_active
!= 'true' ) {
521 Logger
::debug('fsfdrupalauth:' . $this->authId
. ': Discussion board process checks not active');
522 $attributes['discussion_process'] = ['false'];
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'];
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'];
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'];
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))) {
539 Logger
::debug('fsfdrupalauth:' . $this->authId
. ': Past membership / donations meet threshold for discussion board process');
540 $attributes['discussion_process'] = ['true'];
543 Logger
::debug('fsfdrupalauth:' . $this->authId
. ': Past membership / donations do not meet threshold for discussion board process');
544 $attributes['discussion_process'] = ['false'];
548 // discussion forum moderator early access
551 $adhoc_params = ['adhoc_access_group_id' => intval($this->discussion_moderator_access_group_id
)];
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'];
558 Logger
::debug('fsfdrupalauth:' . $this->authId
. ': Not in adhoc list of moderators for board discussion forum');
559 $attributes['discussion_moderator'] = ['false'];
563 // aggregate attribute
568 foreach ($attributes as $key => $value) {
569 if ($value == ['true']) {
571 $groups_list .= ', ';
573 $groups_list .= $key;
578 $attributes['groups_list'] = [$groups_list];
582 * Attempt to log in using the given username and password.
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.
588 * Note that both the username and the password are UTF-8 encoded.
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.
594 protected function login(string $username, string $password): array
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);
601 $user_data = $this->query_db('query_main', ['username' => $username]);
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');
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.
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
621 $attributes['name'][] = $username;
623 foreach ($user_data as $row) {
624 foreach ($row as $key => $value) {
625 if ($value === null) {
629 $value = (string) $value;
631 if (!array_key_exists($key, $attributes)) {
632 $attributes[$key] = [];
635 if (in_array($value, $attributes[$key], true)) {
636 // Value already exists in attribute
640 $attributes[$key][] = $value;
644 if (!$this->check_password($password, $attributes['pass'][0])) {
645 throw new Error\
Error('WRONGUSERPASS');
648 unset($attributes['pass']);
651 $this->add_more_attributes($attributes, $username);
654 Logger
::info('fsfdrupalauth:' . $this->authId
. ': Attributes: ' .
655 implode(',', array_keys($attributes)));