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