Subversion Repositories php_users

Rev

Rev 19 | Rev 21 | Go to most recent revision | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
4 rodolico 1
<?php
2
 
7 rodolico 3
/*
4
   Copyright (c) 2021, Daily Data, Inc. Redistribution and use in 
5
   source and binary forms, with or without modification, are permitted
6
   provided that the following conditions are met:
7
 
8
   * Redistributions of source code must retain the above copyright 
9
     notice, this list of conditions and the following disclaimer.
10
   * Redistributions in binary form must reproduce the above copyright 
11
     notice, this list of conditions and the following disclaimer in the 
12
     documentation and/or other materials provided with the distribution.
13
 
14
   THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 
15
   "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
16
   LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
17
   A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
18
   OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
19
   SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 
20
   LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
21
   DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
22
   THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23
   (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
   OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25
 
26
*/
27
 
28
/*
29
 * users.class.php
30
 * 
31
 * Authors: R. W. Rodolico
32
 * 
33
 */
34
 
35
/**
36
 * User Login class
37
 * 
38
 * IMPORTANT: Requires a data source. See UsersDataSourceMySQLi.class.php
39
 * for code which provides this for MySQLi
40
 * 
41
 * Users encapsulates a basic login and authentication package. It 
42
 * provides a login screen, authentication, the ability to edit oneself,
43
 * and for users with the admin flag set, the ability to edit others.
44
 * 
45
 * It also allows a user to be disabled.
46
 * 
47
 * Users was designed to be extensible, adding new fields by the calling
48
 * program, modifying the HTML elements, etc...
49
 * 
50
 * @author R. W. Rodolico <rodo@unixservertech.com>
51
 * 
52
 * @version 0.9.0 (beta)
53
 * @copyright 2021 Daily Data, Inc.
54
 * 
55
 */
56
 
57
 class Users {
4 rodolico 58
 
7 rodolico 59
   /**
16 rodolico 60
    * @var string[] $configuration Contains the configuration for the class
7 rodolico 61
    * 
62
    * May be modified by the calling program. Must be replicated in userDataSource class
63
    */
16 rodolico 64
   protected $configuration = array(
4 rodolico 65
      /*
66
       * what to use for html input fields
67
       * These are passed to sprintf, with label, fieldname, title, placeholder and current value, in that order
68
       */
69
      'screens'         => array (
70
         'login form' => "<h1>Login</h1>\n<form class='login_form' action='%s' method='post'>\n%s\n<input type='submit' value='Login'></form>\n",
18 rodolico 71
         'edit form'  => "<form class='login_form' action='%s' method='post'>\n%s\n<input type='submit' name='btnUpdate' value='Update'>\n</form>",
7 rodolico 72
         'loginScreen' => "<div class='login_field'>\n<input class='login_field' type='text' name='username' placeholder='Username' required autofocus>\n</div>\n<div class='login_field'>\n<input class='login_field' type='password' name='password' placeholder='Password' required>\n</div>",
4 rodolico 73
         'adminScreen' => "<input type='hidden' name='doAdmin' value='1'>\n",
74
         'validateScript' => '',
75
         ),
76
      'html input fields' => array(
77
            'text'      => "<div class='login_field'>\n<label>%s\n<input class='login_field' type='text' name='%s' title='%s' placeholder='%s' value='~~%s~~'>\n</label>\n</div>",
78
            'password'  => "<div class='login_field'>\n<label>%s\n<input class='login_field' type='password' name='%s' title='%s' placeholder='%s'>\n</label>\n</div>",
79
            'boolean'   => "<div class='login_field'>\n<label>%s\n<input class='login_field' type='checkbox' name='%s' title='%s' value='1' %s ~~%s~~>\n</label>\n</div>",
80
            'textarea'  => "<div class='login_field'>\n<label>%s\n<textarea class='login_field' name='%s' title='%s' placeholder='%s'>~~%s~~</textarea>\n</label>\n</div>",
81
         ),
82
      'input prefix' => 'admin_', // prefix the name with this in a form
83
      'tables'    => array(
84
         'users'  => array(
85
            'table'     => '_users',   // table name for user records
86
            'id'        => '_user_id', // ID column name
87
            'display'   => array(      // fields which are displayed to select
88
               'login'
89
               ),         
90
            'form test' => 'login',    // field to test if form submitted
91
            'fields' => array(
92
               'login'  => array(
93
                     'label'        => 'Username',       // login name column name
94
                     'html type'    => 'text',
10 rodolico 95
                     'filter'       => '/^[a-zA-Z0-9_]+$/',
4 rodolico 96
                     'instructions' => 'Username can only contain alpha numerics and an underscore',
97
                     'hint'         => 'Change User Name'
98
                     ),
99
               'pass'   => array( 
100
                     'label'        => 'Password',    // password column name
101
                     'html type'    => 'password',
102
                     'instructions' => 'Leave blank to keep same password',
103
                     'hint'         => 'Change Password'
104
                     ),
105
               'admin'  => array(
106
                     'label'        => 'isAdmin',
107
                     'html type'    => 'boolean',
108
                     'restrict'     => true,
109
                     'instructions' => 'If checked, user will be able to add/edit users',
110
                     ),
111
               'enabled' => array(
112
                     'label'        => 'Enabled',
113
                     'html type'    => 'boolean',
114
                     'restrict'     => true,
115
                     'instructions' => 'Uncheck to disable log in',
116
                     ),
117
               ) // fields
118
            ) // table users
119
         ) // tables
120
      );
121
 
7 rodolico 122
   /** @var string[] $data Contains the information for the current logged in user */
16 rodolico 123
   protected $data = array();
7 rodolico 124
   /** @var string[] $errors Contains errors that can occur */
16 rodolico 125
   protected $errors = array();
7 rodolico 126
   /** @var string[] $workingOn During administration, contains the record being modified */
16 rodolico 127
   protected $workingOn = array();
7 rodolico 128
 
129
   /**
130
    * constructor for an instance of the class
131
    * 
16 rodolico 132
    * Anything in $customFields will be recursively merged with $configuration, overwriting
7 rodolico 133
    * as necessary.
134
    * 
16 rodolico 135
    * @param string[] $customFields array to merge into $configuration
7 rodolico 136
    */
4 rodolico 137
   public function __construct( $customFields = array() ) {
138
      if ( $customFields ) {
16 rodolico 139
         $this->configuration = array_merge_recursive( $this->configuration, $customFields );
4 rodolico 140
      }
141
   } // constructor
142
 
7 rodolico 143
   /**
144
    * getter for $this->errors
145
    * 
146
    * @return string html div containing one paragraph for every error
147
    */
148
   public function errors() {
149
      $return = "<p>" . implode( "</p>\n<p>", $this->errors ) . "</p>\n";
150
      return "<div class='login_errors'>\n$return</div>\n";
151
   }
152
 
153
   /**
154
    * clears the errors array
155
    */
156
   public function clearErrors() {
157
      $this->errors = array();
158
   }
159
 
160
   /**
161
    * getter for isAdmin
162
    * 
163
    * @return boolean true if user is an admin, false if not
164
    */
4 rodolico 165
   public function isAdmin() {
166
      return $this->data['admin'];
167
   }
168
 
7 rodolico 169
   /**
170
    * getter for login name
171
    * 
172
    * @return string user name 
173
    */
4 rodolico 174
   public function name() {
175
      return isset( $this->data['login'] ) ? $this->data['login'] : null;
176
   }
177
 
7 rodolico 178
 
179
   /**
180
    * Main display function.
181
    * 
182
    * This function should be called to perform the login. It performs all functions
183
    * needed to log in and validate, but once logged in, will return an empty string.
184
    * 
185
    * @param usersDataSource $connection A connection to the data source
186
    * @param string $nextScript The url to be run when logged in
187
    * 
188
    * @return string A (possibly empty) HTML div
189
    */
4 rodolico 190
   public function HTML( $connection, $nextScript = null ) {
191
      if ( isset( $_REQUEST['username'], $_REQUEST['password'] ) ) {
192
         $this->validate( $_REQUEST['username'], $_REQUEST['password'], $connection );
193
      }
194
      if ( isset( $_REQUEST['logout'] ) && $_REQUEST['logout'] == 'Logout' ) {
195
         $this->logOut();
196
      }
197
      if ( ! isset( $this->data['login'], $this->data['id'] ) ) {
198
         return $this->logInScreen();
199
      }
200
   }
201
 
7 rodolico 202
   /**
203
    * Validates a connection and, on success, populates $data
204
    * 
205
    * Function will validate the username and password passed in, using
206
    * data connection $connection. On success, populates class member $data
16 rodolico 207
    * with the values from the database (only those listed in $configuration)
7 rodolico 208
    * 
209
    * On Failure, appends $error with a failure string
210
    * 
211
    * @param string $username The username to be matched in database
212
    * @param string $password The password (unencrypted) the user entered
213
    * @param usersDataSource $connection A connection to the data source
214
    * 
215
    */
16 rodolico 216
   protected function validate( $username, $password, $connection ) {
4 rodolico 217
      $result = $connection->getPassword( $username );
218
      if ( password_verify( $password, $result['pass'] ) ) {
18 rodolico 219
         $result = $connection->getARecord( array( 'login' => $username ) );
4 rodolico 220
         $this->data['id'] = $result['id'];
16 rodolico 221
         foreach ( $this->configuration['tables']['users']['fields'] as $key => $record ) {
4 rodolico 222
            if ( $key != 'pass' )
223
               $this->data[$key] = $result[$key];
224
         }
17 rodolico 225
         return true;
4 rodolico 226
      } else {
7 rodolico 227
         $this->errors[] = 'Login Failed: Unknown username or password';
16 rodolico 228
         foreach ( $this->configuration['tables']['users']['fields'] as $key => $record ) {
4 rodolico 229
            $this->data[$key] = null;
230
         }
17 rodolico 231
         return false;
4 rodolico 232
      }
233
   } // validate
234
 
7 rodolico 235
   /**
236
    * Get all users from data source and put them in an HTML list
237
    * 
238
    * Will retrieve the ID and login name of all users, putting them
239
    * in a list of anchors to allow an admin to select one for editing
240
    * 
241
    * @param  usersDataSource $connection A connection to the data source
242
    * @param string $nextPage The URL of the page to be used in the link
243
    * 
244
    * @return  string   an unordered list (UL) containing links with names
245
    */
4 rodolico 246
   public function allUsersHTML ( $connection, $nextPage = null ) {
247
      $nextPage = self::getNextScript( $nextPage );
248
      $return = '';
249
      $allUsers = $connection->getAllUsers();
250
      foreach ( $allUsers as $row ) {
251
         if ( $row['id'] == $this->data['id'] ) // don't do ourselves
252
            continue;
253
         $return .= sprintf( "<li><a href='$nextPage?doAdmin=1&id=%s'>%s</a></li>\n", $row['id'], $row['login'] );
254
      }
255
      $return .= sprintf( "<li><a href='$nextPage?doAdmin=1&id=%s'>%s</a></li>\n", -1, 'Add New User' );
256
      // wrap in ul, then put a div around it
257
      $return = "<ul class='login_list'>\n$return\n</ul>\n";
258
      $return = "<div class='login_list'>\n$return\n</div>\n";
259
      return $return;
260
   }
261
 
7 rodolico 262
   /**
263
    * Logs user out of system
264
    * 
265
    * destroys itself ($_SESSION['user'], then session, then calls
266
    * $nextScript by doing a header call.
267
    * 
268
    * @param string $nextScript URL of next script to call
269
    */
4 rodolico 270
   public function logOut( $nextScript = null ) {
271
      $nextScript = $this->getNextScript( $nextScript );
272
      $_SESSION['user'] = null;
273
      session_destroy();
274
      header( "Location: $nextScript" );
275
   }
276
 
7 rodolico 277
   /**
278
    * Simple helper script to calculate next script to call
279
    * 
280
    * Returns one of three URL strings, in order of precedence
281
    * $nextScript
16 rodolico 282
    * $configuration['screens']['validateScript']
7 rodolico 283
    * PHP_SELF
284
    * 
285
    * @param string $nextScript URL to call
286
    * @return string URL
287
    */
16 rodolico 288
   protected function getNextScript( $nextScript = null ) {
4 rodolico 289
      if ( ! isset( $nextScript ) ) {
16 rodolico 290
         $nextScript = $this->configuration['screens']['validateScript'] ?:
4 rodolico 291
                           htmlentities($_SERVER["PHP_SELF"]);
292
      }
293
      return $nextScript;
294
   }
295
 
7 rodolico 296
   /**
297
    * Creates the fields needed for a login screen
298
    * 
299
    * Populates %s's in 'login form' with values for $nextScript and
300
    * 'loginScreen'
301
    * 
302
    * @param string $nextScript URL to call form
303
    * 
304
    * @return string HTML code for display
305
    */
16 rodolico 306
   protected function logInScreen( $nextScript = null ) {
7 rodolico 307
      $return =  sprintf( 
16 rodolico 308
         $this->configuration['screens']['login form'],
4 rodolico 309
         $this->getNextScript( $nextScript ),
16 rodolico 310
         $this->configuration['screens']['loginScreen']
4 rodolico 311
      );
7 rodolico 312
      $return .= $this->errors();
313
      $this->clearErrors();
314
      return $return;
4 rodolico 315
   }
316
 
7 rodolico 317
   /**
318
    * Creates an HTML field for display
319
    * 
320
    * Retrieves the template for the record type, then populates it from
321
    * $record, $value and $field. The template MUST have %s's in the 
322
    * following order for an HTML INPUT field
323
    * label=
324
    * name=
325
    * title=
326
    * placeholder=
327
    * value
328
    * 
329
    * Knows how to handle INPUT types TEXT, TEXTAREA, PASSWORD and 
330
    * special html type boolean, which is checkboxes.
331
    * 
332
    * @param string $field name of the field to populate
16 rodolico 333
    * @param string[] $record Record from $configuration[...][fields]
7 rodolico 334
    * @param string $value the current value to put in INPUT
335
    * 
336
    * @return string An HTML INPUT entity
337
    */
16 rodolico 338
   protected function makeHTMLField ( $field, $record, $value ) {
4 rodolico 339
      $return = array();
16 rodolico 340
      $temp = sprintf( $this->configuration['html input fields'][$record['html type']], 
4 rodolico 341
                        $record['label'] ?: $field,
16 rodolico 342
                        $this->configuration['input prefix'] . $field, 
4 rodolico 343
                        !empty($record['instructions']) ? $record['instructions'] : '',
344
                        !empty($record['hint']) ? $record['hint'] : '',
345
                        $field
346
                     );
347
 
348
      switch ($record['html type'] ) {
349
         case 'text':
350
         case 'textarea':
351
                        $temp = preg_replace( "/~~$field~~/", isset( $value ) ? $value : '', $temp );
352
                        break;
353
         case 'password' :
354
                        break;
355
         case 'boolean' :  // boolean is set by checkboxes
356
                        $temp = preg_replace( "/~~$field~~/", $value ? 'checked' : '', $temp );
357
                        break;
358
      } // case
359
      return $temp;
360
 
361
   } // makeHTMLField
362
 
7 rodolico 363
   /**
364
    * Creates an edit screen for display to user
365
    * 
366
    * This function will create an edit screen which, when displayed to
367
    * the user, allows them to edit a users record. The record is stored
368
    * in $this->workingOn
369
    * 
370
    * Function will go through each field in the users table and call makeHTMLField
371
    * for it, unless the field is restricted and the user is editing their own
372
    * entry. It will also create a hidden input field with the users ID
373
    * 
374
    * NOTE: this will not create the form; the form is created someplace else
375
    * 
376
    * @return string HTML containing all of the INPUT records a user can edit
377
    */
17 rodolico 378
   public function editScreen( $connection ) {
4 rodolico 379
      $return = array();
16 rodolico 380
      $return[] = $this->configuration['screens']['adminScreen'];
18 rodolico 381
      $return[] = "<input type='hidden' name='id' value='" . $this->workingOn['id'] . "'>\n";
16 rodolico 382
      foreach ( $this->configuration['tables']['users']['fields'] as $field => $record ) {
4 rodolico 383
         // if this field is restricted and we are not admin, just skip it
384
         // also skip if it is our record
385
         if ( isset( $record['restrict'] ) && ( $this->data['id'] == $this->workingOn['id'] ) )
386
            continue;
387
         // now process the field
388
         $return[] = $this->makeHTMLField( $field, $record, $this->workingOn[$field] ?? '' );
389
      }
390
      return implode( "\n", $return );
391
   } // editScreen
392
 
7 rodolico 393
   /**
394
    * Creates a variable designed to replace $this->workingOn
395
    * 
396
    * Initializes all fields to something non-null and sets id to -1
397
    * 
398
    * @return string[] An array initialized with all records needed
399
    */
16 rodolico 400
   protected function emptyWorkingOn() {
4 rodolico 401
      $new = array();
402
      $new['id'] = -1;
16 rodolico 403
      foreach ( $this->configuration['tables']['users']['fields'] as $field => $record ) {
4 rodolico 404
         if ( isset( $record['default'] ) ) {
405
            $new[$field] = $record['default'];
406
         } else {
407
            switch ($record['html type']) {
408
               case 'text'    :
409
               case 'blob'    :  $new[$field] = '';
410
                                 break;
411
               case 'boolean' :  $new[$field] = 1;
412
                                 break;
413
               case 'password':  $new[$field] = '';
414
                                 break;
415
            }
416
         } // else
417
      } // foreach
418
      return $new;
419
   }
420
 
17 rodolico 421
   protected function addEdit( $connection ) {
422
      $data = array();
423
      foreach ( $this->configuration['tables']['users']['fields'] as $field => $record ) {
424
         // if this field is restricted it is our record, skip it
425
         if ( isset( $record['restrict'] ) && ( $this->data['id'] == $this->workingOn['id'] ) )
426
            continue;
427
         $htmlFieldName = $this->configuration['input prefix'] . $field;
428
         $temp = '';
429
         switch ( $record['html type'] ) {
430
            case 'password':
431
               if ( ! empty( $_REQUEST[$htmlFieldName] ) ) {
432
                  $data[$field] = password_hash( $_REQUEST[$htmlFieldName], PASSWORD_DEFAULT );
433
                  if ( isset( $this->configuration['tables']['users']['fields']['last password change'] ) ) {
434
                     $data['last password change'] = date("YmdHis");
435
                  }
436
               }
437
               break;
438
            case 'boolean' :
439
               if ( $this->workingOn['id'] == -1 || ( isset( $_REQUEST[$htmlFieldName] ) != $this->workingOn[$field] ) ) {
440
                  $data[$field] = isset( $_REQUEST[$htmlFieldName] ) ? 1 : 0;
441
               }
442
               break;
443
            default : // text, textarea, other things like this
444
               if ( $this->workingOn['id'] == -1 || ( isset( $_REQUEST[$htmlFieldName] ) && $_REQUEST[$htmlFieldName] !== $this->workingOn[$field] ) ) {
445
                  $data[$field] = $_REQUEST[$htmlFieldName];
446
                  if ( isset( $record['filter'] ) && preg_match( $record['filter'], $data[$field] ) !== 1 ) {
447
                     $this->errors[] = sprintf( "Invalid characters in %s, %s", $record['label'], $record['instructions'] );
448
                     unset( $data[$field] );
449
                  }
450
               }
451
               break;
452
         } // switch
453
      } // foreach
454
      if ( count($this->errors) ) { // we have some errors
455
         $this->errors[] = 'Record not updated';
456
         return 'Error';
457
      }
458
      if ( $data ) {
459
         $data['id'] = $this->workingOn['id'];
460
         $return = $connection->update( $data ) ? "Updated" : "Failed";
461
      } else {
462
         $return = "No changes";
463
      }
464
   }
465
 
466
   protected function initWorkingOn( $connection, $id ) {
18 rodolico 467
      if ( ! isset($id) || $id == $this->data['id'] ) { // we're working on ourself
17 rodolico 468
         $this->workingOn = $this->data;
18 rodolico 469
      } elseif ( $id == -1 ) { // a new user
470
         $this->workingOn = $this->emptyWorkingOn();
471
      } else { // this is an existing user
472
         $this->workingOn = $connection->getARecord( array( 'id' => $id ) );
17 rodolico 473
      }
18 rodolico 474
   } // initWorkingOn
17 rodolico 475
 
18 rodolico 476
 
477
 
7 rodolico 478
   /**
479
    * Sets up the admin function which allows users to edit themselves and, optionally, others
480
    * 
481
    * This should be called the first time, then repeatedly called until it is done
482
    * (it returns the string "Updated", "Failed" or "No changes".
483
    * 
484
    * The first iteration returns an edit screen displaying the users
485
    * information for them to edit. It will display an HTML INPUT for
486
    * each field that is not restricted. The user can then edit the
487
    * chosen entries and press the button, which will call the script
488
    * again, and update the record.
489
    * 
490
    * If the user has the admin right, the Edit screen also displays a 
491
    * list of all users as an unsigned list of anchors. If the user 
492
    * clicks on one of those, it will choose that user, load their data
493
    * and allow the user to edit that users record. NOTE: this is the
494
    * only way to edit fields with the restrict flag set.
495
    * 
496
    * @param  usersDataSource $connection A connection to the data source
497
    * @param string $nextPage The URL of the page to be used in the link
498
    * 
499
    * @return string This may be an HTML table or a single screen
500
    */
4 rodolico 501
   public function admin ( $connection, $nextScript = null ) {
18 rodolico 502
      /*
503
       * Entering for first time, 
504
       *    admin=1, $workingOn not set
505
       *    update workingOn
506
       *    display screen
507
       * entering after selecting a user
508
       *    doAdmin=1, id=#, $workingOn[id] <> id
509
       *    update workingOn
510
       *    display screen
511
       * Have pressed update button
512
       *    update
513
       */
514
 
4 rodolico 515
      $nextScript = $this->getNextScript( $nextScript );
18 rodolico 516
      $id = isset($_REQUEST['id']) ? $_REQUEST['id'] : null;
19 rodolico 517
      $return = array();
518
      if ( ! $this->workingOn || $this->workingOn['id'] != $id ) {
519
         // display screen for data entry
520
         //$return[] = 'Initializing $workingOn';
18 rodolico 521
         $this->initWorkingOn( $connection, $id );
19 rodolico 522
         //$return[] = "<pre>WorkingOn\n" . print_r( $this->workingOn, true) . '</pre>';
523
         $screen = $this->editScreen( $connection );
4 rodolico 524
         if ( $this->data['admin'] ) {
19 rodolico 525
            $screen .= $this->allUsersHTML( $connection );
4 rodolico 526
         }
19 rodolico 527
         $return[] = sprintf( $this->configuration['screens']['edit form'],
4 rodolico 528
            $nextScript,
19 rodolico 529
            $screen
4 rodolico 530
            );
18 rodolico 531
      } elseif ( $_SERVER['REQUEST_METHOD'] === 'POST' ) { // they submitted the form
19 rodolico 532
         //$return[] = 'Posting';
533
         $return[] = $this->addEdit( $connection );
18 rodolico 534
         /*
535
          * if ( $this->workingOn['id'] == $this->data['id'] ) // we just updated us, reload record
536
            $this->data = $connection->getARecord( array( 'id' => $this->data['id'] ) );
537
         */
4 rodolico 538
         unset( $this->workingOn );
19 rodolico 539
      } else {
540
         print "<h3>We're in the damned 'else' in admin</h3>";
18 rodolico 541
      }
19 rodolico 542
      return implode( "<br />\n", $return );
4 rodolico 543
   } // admin
544
 
545
} // class Users
546
 
547
?>