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