Subversion Repositories php_users

Rev

Rev 16 | Rev 18 | 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",
71
         'edit form'  => "<form class='login_form' action='%s' method='post'>\n%s\n<input type='submit' id='btnUpdate' 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
 
17 rodolico 129
   public function data() {
130
      return $this->data;
131
   }
132
 
7 rodolico 133
   /**
134
    * constructor for an instance of the class
135
    * 
16 rodolico 136
    * Anything in $customFields will be recursively merged with $configuration, overwriting
7 rodolico 137
    * as necessary.
138
    * 
16 rodolico 139
    * @param string[] $customFields array to merge into $configuration
7 rodolico 140
    */
4 rodolico 141
   public function __construct( $customFields = array() ) {
142
      if ( $customFields ) {
16 rodolico 143
         $this->configuration = array_merge_recursive( $this->configuration, $customFields );
4 rodolico 144
      }
145
   } // constructor
146
 
7 rodolico 147
   /**
148
    * getter for $this->errors
149
    * 
150
    * @return string html div containing one paragraph for every error
151
    */
152
   public function errors() {
153
      $return = "<p>" . implode( "</p>\n<p>", $this->errors ) . "</p>\n";
154
      return "<div class='login_errors'>\n$return</div>\n";
155
   }
156
 
157
   /**
158
    * clears the errors array
159
    */
160
   public function clearErrors() {
161
      $this->errors = array();
162
   }
163
 
164
   /**
165
    * getter for isAdmin
166
    * 
167
    * @return boolean true if user is an admin, false if not
168
    */
4 rodolico 169
   public function isAdmin() {
170
      return $this->data['admin'];
171
   }
172
 
7 rodolico 173
   /**
174
    * getter for login name
175
    * 
176
    * @return string user name 
177
    */
4 rodolico 178
   public function name() {
179
      return isset( $this->data['login'] ) ? $this->data['login'] : null;
180
   }
181
 
7 rodolico 182
 
183
   /**
184
    * Main display function.
185
    * 
186
    * This function should be called to perform the login. It performs all functions
187
    * needed to log in and validate, but once logged in, will return an empty string.
188
    * 
189
    * @param usersDataSource $connection A connection to the data source
190
    * @param string $nextScript The url to be run when logged in
191
    * 
192
    * @return string A (possibly empty) HTML div
193
    */
4 rodolico 194
   public function HTML( $connection, $nextScript = null ) {
195
      if ( isset( $_REQUEST['username'], $_REQUEST['password'] ) ) {
196
         $this->validate( $_REQUEST['username'], $_REQUEST['password'], $connection );
197
      }
198
      if ( isset( $_REQUEST['logout'] ) && $_REQUEST['logout'] == 'Logout' ) {
199
         $this->logOut();
200
      }
201
      if ( ! isset( $this->data['login'], $this->data['id'] ) ) {
202
         return $this->logInScreen();
203
      }
204
   }
205
 
7 rodolico 206
   /**
207
    * Validates a connection and, on success, populates $data
208
    * 
209
    * Function will validate the username and password passed in, using
210
    * data connection $connection. On success, populates class member $data
16 rodolico 211
    * with the values from the database (only those listed in $configuration)
7 rodolico 212
    * 
213
    * On Failure, appends $error with a failure string
214
    * 
215
    * @param string $username The username to be matched in database
216
    * @param string $password The password (unencrypted) the user entered
217
    * @param usersDataSource $connection A connection to the data source
218
    * 
219
    */
16 rodolico 220
   protected function validate( $username, $password, $connection ) {
4 rodolico 221
      $result = $connection->getPassword( $username );
222
      if ( password_verify( $password, $result['pass'] ) ) {
223
         $result = $connection->getRecord( $username );
224
         $this->data['id'] = $result['id'];
16 rodolico 225
         foreach ( $this->configuration['tables']['users']['fields'] as $key => $record ) {
4 rodolico 226
            if ( $key != 'pass' )
227
               $this->data[$key] = $result[$key];
228
         }
17 rodolico 229
         return true;
4 rodolico 230
      } else {
7 rodolico 231
         $this->errors[] = 'Login Failed: Unknown username or password';
16 rodolico 232
         foreach ( $this->configuration['tables']['users']['fields'] as $key => $record ) {
4 rodolico 233
            $this->data[$key] = null;
234
         }
17 rodolico 235
         return false;
4 rodolico 236
      }
237
   } // validate
238
 
7 rodolico 239
   /**
240
    * Get all users from data source and put them in an HTML list
241
    * 
242
    * Will retrieve the ID and login name of all users, putting them
243
    * in a list of anchors to allow an admin to select one for editing
244
    * 
245
    * @param  usersDataSource $connection A connection to the data source
246
    * @param string $nextPage The URL of the page to be used in the link
247
    * 
248
    * @return  string   an unordered list (UL) containing links with names
249
    */
4 rodolico 250
   public function allUsersHTML ( $connection, $nextPage = null ) {
251
      $nextPage = self::getNextScript( $nextPage );
252
      $return = '';
253
      $allUsers = $connection->getAllUsers();
254
      foreach ( $allUsers as $row ) {
255
         if ( $row['id'] == $this->data['id'] ) // don't do ourselves
256
            continue;
257
         $return .= sprintf( "<li><a href='$nextPage?doAdmin=1&id=%s'>%s</a></li>\n", $row['id'], $row['login'] );
258
      }
259
      $return .= sprintf( "<li><a href='$nextPage?doAdmin=1&id=%s'>%s</a></li>\n", -1, 'Add New User' );
260
      // wrap in ul, then put a div around it
261
      $return = "<ul class='login_list'>\n$return\n</ul>\n";
262
      $return = "<div class='login_list'>\n$return\n</div>\n";
263
      return $return;
264
   }
265
 
7 rodolico 266
   /**
267
    * Logs user out of system
268
    * 
269
    * destroys itself ($_SESSION['user'], then session, then calls
270
    * $nextScript by doing a header call.
271
    * 
272
    * @param string $nextScript URL of next script to call
273
    */
4 rodolico 274
   public function logOut( $nextScript = null ) {
275
      $nextScript = $this->getNextScript( $nextScript );
276
      $_SESSION['user'] = null;
277
      session_destroy();
278
      header( "Location: $nextScript" );
279
   }
280
 
7 rodolico 281
   /**
282
    * Simple helper script to calculate next script to call
283
    * 
284
    * Returns one of three URL strings, in order of precedence
285
    * $nextScript
16 rodolico 286
    * $configuration['screens']['validateScript']
7 rodolico 287
    * PHP_SELF
288
    * 
289
    * @param string $nextScript URL to call
290
    * @return string URL
291
    */
16 rodolico 292
   protected function getNextScript( $nextScript = null ) {
4 rodolico 293
      if ( ! isset( $nextScript ) ) {
16 rodolico 294
         $nextScript = $this->configuration['screens']['validateScript'] ?:
4 rodolico 295
                           htmlentities($_SERVER["PHP_SELF"]);
296
      }
297
      return $nextScript;
298
   }
299
 
7 rodolico 300
   /**
301
    * Creates the fields needed for a login screen
302
    * 
303
    * Populates %s's in 'login form' with values for $nextScript and
304
    * 'loginScreen'
305
    * 
306
    * @param string $nextScript URL to call form
307
    * 
308
    * @return string HTML code for display
309
    */
16 rodolico 310
   protected function logInScreen( $nextScript = null ) {
7 rodolico 311
      $return =  sprintf( 
16 rodolico 312
         $this->configuration['screens']['login form'],
4 rodolico 313
         $this->getNextScript( $nextScript ),
16 rodolico 314
         $this->configuration['screens']['loginScreen']
4 rodolico 315
      );
7 rodolico 316
      $return .= $this->errors();
317
      $this->clearErrors();
318
      return $return;
4 rodolico 319
   }
320
 
7 rodolico 321
   /**
322
    * Creates an HTML field for display
323
    * 
324
    * Retrieves the template for the record type, then populates it from
325
    * $record, $value and $field. The template MUST have %s's in the 
326
    * following order for an HTML INPUT field
327
    * label=
328
    * name=
329
    * title=
330
    * placeholder=
331
    * value
332
    * 
333
    * Knows how to handle INPUT types TEXT, TEXTAREA, PASSWORD and 
334
    * special html type boolean, which is checkboxes.
335
    * 
336
    * @param string $field name of the field to populate
16 rodolico 337
    * @param string[] $record Record from $configuration[...][fields]
7 rodolico 338
    * @param string $value the current value to put in INPUT
339
    * 
340
    * @return string An HTML INPUT entity
341
    */
16 rodolico 342
   protected function makeHTMLField ( $field, $record, $value ) {
4 rodolico 343
      $return = array();
16 rodolico 344
      $temp = sprintf( $this->configuration['html input fields'][$record['html type']], 
4 rodolico 345
                        $record['label'] ?: $field,
16 rodolico 346
                        $this->configuration['input prefix'] . $field, 
4 rodolico 347
                        !empty($record['instructions']) ? $record['instructions'] : '',
348
                        !empty($record['hint']) ? $record['hint'] : '',
349
                        $field
350
                     );
351
 
352
      switch ($record['html type'] ) {
353
         case 'text':
354
         case 'textarea':
355
                        $temp = preg_replace( "/~~$field~~/", isset( $value ) ? $value : '', $temp );
356
                        break;
357
         case 'password' :
358
                        break;
359
         case 'boolean' :  // boolean is set by checkboxes
360
                        $temp = preg_replace( "/~~$field~~/", $value ? 'checked' : '', $temp );
361
                        break;
362
      } // case
363
      return $temp;
364
 
365
   } // makeHTMLField
366
 
7 rodolico 367
   /**
368
    * Creates an edit screen for display to user
369
    * 
370
    * This function will create an edit screen which, when displayed to
371
    * the user, allows them to edit a users record. The record is stored
372
    * in $this->workingOn
373
    * 
374
    * Function will go through each field in the users table and call makeHTMLField
375
    * for it, unless the field is restricted and the user is editing their own
376
    * entry. It will also create a hidden input field with the users ID
377
    * 
378
    * NOTE: this will not create the form; the form is created someplace else
379
    * 
380
    * @return string HTML containing all of the INPUT records a user can edit
381
    */
17 rodolico 382
   public function editScreen( $connection ) {
4 rodolico 383
      $return = array();
16 rodolico 384
      $return[] = $this->configuration['screens']['adminScreen'];
4 rodolico 385
      $return[] = "<input type='hidden' name='id' value=" . $this->workingOn['id'] . "'>\n";
16 rodolico 386
      foreach ( $this->configuration['tables']['users']['fields'] as $field => $record ) {
4 rodolico 387
         // if this field is restricted and we are not admin, just skip it
388
         // also skip if it is our record
389
         if ( isset( $record['restrict'] ) && ( $this->data['id'] == $this->workingOn['id'] ) )
390
            continue;
391
         // now process the field
392
         $return[] = $this->makeHTMLField( $field, $record, $this->workingOn[$field] ?? '' );
393
      }
394
      return implode( "\n", $return );
395
   } // editScreen
396
 
7 rodolico 397
   /**
398
    * Creates a variable designed to replace $this->workingOn
399
    * 
400
    * Initializes all fields to something non-null and sets id to -1
401
    * 
402
    * @return string[] An array initialized with all records needed
403
    */
16 rodolico 404
   protected function emptyWorkingOn() {
4 rodolico 405
      $new = array();
406
      $new['id'] = -1;
16 rodolico 407
      foreach ( $this->configuration['tables']['users']['fields'] as $field => $record ) {
4 rodolico 408
         if ( isset( $record['default'] ) ) {
409
            $new[$field] = $record['default'];
410
         } else {
411
            switch ($record['html type']) {
412
               case 'text'    :
413
               case 'blob'    :  $new[$field] = '';
414
                                 break;
415
               case 'boolean' :  $new[$field] = 1;
416
                                 break;
417
               case 'password':  $new[$field] = '';
418
                                 break;
419
            }
420
         } // else
421
      } // foreach
422
      return $new;
423
   }
424
 
17 rodolico 425
   protected function addEdit( $connection ) {
426
      $data = array();
427
      foreach ( $this->configuration['tables']['users']['fields'] as $field => $record ) {
428
         // if this field is restricted it is our record, skip it
429
         if ( isset( $record['restrict'] ) && ( $this->data['id'] == $this->workingOn['id'] ) )
430
            continue;
431
         $htmlFieldName = $this->configuration['input prefix'] . $field;
432
         $temp = '';
433
         switch ( $record['html type'] ) {
434
            case 'password':
435
               if ( ! empty( $_REQUEST[$htmlFieldName] ) ) {
436
                  $data[$field] = password_hash( $_REQUEST[$htmlFieldName], PASSWORD_DEFAULT );
437
                  if ( isset( $this->configuration['tables']['users']['fields']['last password change'] ) ) {
438
                     $data['last password change'] = date("YmdHis");
439
                  }
440
               }
441
               break;
442
            case 'boolean' :
443
               if ( $this->workingOn['id'] == -1 || ( isset( $_REQUEST[$htmlFieldName] ) != $this->workingOn[$field] ) ) {
444
                  $data[$field] = isset( $_REQUEST[$htmlFieldName] ) ? 1 : 0;
445
               }
446
               break;
447
            default : // text, textarea, other things like this
448
               if ( $this->workingOn['id'] == -1 || ( isset( $_REQUEST[$htmlFieldName] ) && $_REQUEST[$htmlFieldName] !== $this->workingOn[$field] ) ) {
449
                  $data[$field] = $_REQUEST[$htmlFieldName];
450
                  if ( isset( $record['filter'] ) && preg_match( $record['filter'], $data[$field] ) !== 1 ) {
451
                     $this->errors[] = sprintf( "Invalid characters in %s, %s", $record['label'], $record['instructions'] );
452
                     unset( $data[$field] );
453
                  }
454
               }
455
               break;
456
         } // switch
457
      } // foreach
458
      if ( count($this->errors) ) { // we have some errors
459
         $this->errors[] = 'Record not updated';
460
         return 'Error';
461
      }
462
      if ( $data ) {
463
         $data['id'] = $this->workingOn['id'];
464
         $return = $connection->update( $data ) ? "Updated" : "Failed";
465
         if ( $this->workingOn['id'] == $this->data['id'] ) // we just updated us, reload record
466
            $this->data = $connection->getARecord( array( 'id' => $this->data['id'] ) );
467
      } else {
468
         $return = "No changes";
469
      }
470
   }
471
 
472
   protected function initWorkingOn( $connection, $id ) {
473
      if ( ! isset($id) ) {
474
         // we're working on ourself
475
         $this->workingOn = $this->data;
476
      } elseif ( isset($id ) && $this->workingOn['id'] != $id ) {
477
         // we're working on a different user
478
         if ( $id == -1 ) { // we are adding a new user
479
            $this->workingOn = $this->emptyWorkingOn();
480
         } else { // this is an existing user
481
            $this->workingOn = $connection->getARecord( array( 'id' => $id ) );
482
         }
483
      }
484
      // default to working on ourself
485
      if ( ! ( isset( $this->workingOn ) && count( $this->workingOn ) ) ) {
486
         $this->workingOn = $this->data;
487
      }
488
   }
489
 
7 rodolico 490
   /**
491
    * Sets up the admin function which allows users to edit themselves and, optionally, others
492
    * 
493
    * This should be called the first time, then repeatedly called until it is done
494
    * (it returns the string "Updated", "Failed" or "No changes".
495
    * 
496
    * The first iteration returns an edit screen displaying the users
497
    * information for them to edit. It will display an HTML INPUT for
498
    * each field that is not restricted. The user can then edit the
499
    * chosen entries and press the button, which will call the script
500
    * again, and update the record.
501
    * 
502
    * If the user has the admin right, the Edit screen also displays a 
503
    * list of all users as an unsigned list of anchors. If the user 
504
    * clicks on one of those, it will choose that user, load their data
505
    * and allow the user to edit that users record. NOTE: this is the
506
    * only way to edit fields with the restrict flag set.
507
    * 
508
    * @param  usersDataSource $connection A connection to the data source
509
    * @param string $nextPage The URL of the page to be used in the link
510
    * 
511
    * @return string This may be an HTML table or a single screen
512
    */
4 rodolico 513
   public function admin ( $connection, $nextScript = null ) {
514
      $nextScript = $this->getNextScript( $nextScript );
17 rodolico 515
      // set workingOn if not set
516
      if ( ! $this->workingOn || isset($_REQUEST['id']) )
517
         $this->initWorkingOn( $connection, isset($_REQUEST['id']) ? $_REQUEST['id'] : null );
4 rodolico 518
      // we have no data, so we should create a form for them to enter something
16 rodolico 519
      if ( ! isset( $_REQUEST[$this->configuration['input prefix'] . $this->configuration['tables']['users']['form test']] ) ) {
4 rodolico 520
         // create the screen
17 rodolico 521
         $return = $this->editScreen( $connection );
4 rodolico 522
         if ( $this->data['admin'] ) {
7 rodolico 523
            $return .= $this->allUsersHTML( $connection );
4 rodolico 524
         }
16 rodolico 525
         return sprintf( $this->configuration['screens']['edit form'],
4 rodolico 526
            $nextScript,
527
            $return
528
            );
529
      } else { // we are processing
17 rodolico 530
         $return = $this->addEdit( $connection );
4 rodolico 531
         unset( $this->workingOn );
532
         return $return;
533
      } // else
534
   } // admin
535
 
536
} // class Users
537
 
538
?>