Subversion Repositories php_users

Rev

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