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