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