Subversion Repositories php_users

Rev

Rev 10 | Rev 17 | 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
 
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'] ) ) {
219
         $result = $connection->getRecord( $username );
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
         }
225
      } else {
7 rodolico 226
         $this->errors[] = 'Login Failed: Unknown username or password';
16 rodolico 227
         foreach ( $this->configuration['tables']['users']['fields'] as $key => $record ) {
4 rodolico 228
            $this->data[$key] = null;
229
         }
230
      }
231
   } // validate
232
 
7 rodolico 233
   /**
234
    * Get all users from data source and put them in an HTML list
235
    * 
236
    * Will retrieve the ID and login name of all users, putting them
237
    * in a list of anchors to allow an admin to select one for editing
238
    * 
239
    * @param  usersDataSource $connection A connection to the data source
240
    * @param string $nextPage The URL of the page to be used in the link
241
    * 
242
    * @return  string   an unordered list (UL) containing links with names
243
    */
4 rodolico 244
   public function allUsersHTML ( $connection, $nextPage = null ) {
245
      $nextPage = self::getNextScript( $nextPage );
246
      $return = '';
247
      $allUsers = $connection->getAllUsers();
248
      foreach ( $allUsers as $row ) {
249
         if ( $row['id'] == $this->data['id'] ) // don't do ourselves
250
            continue;
251
         $return .= sprintf( "<li><a href='$nextPage?doAdmin=1&id=%s'>%s</a></li>\n", $row['id'], $row['login'] );
252
      }
253
      $return .= sprintf( "<li><a href='$nextPage?doAdmin=1&id=%s'>%s</a></li>\n", -1, 'Add New User' );
254
      // wrap in ul, then put a div around it
255
      $return = "<ul class='login_list'>\n$return\n</ul>\n";
256
      $return = "<div class='login_list'>\n$return\n</div>\n";
257
      return $return;
258
   }
259
 
7 rodolico 260
   /**
261
    * Logs user out of system
262
    * 
263
    * destroys itself ($_SESSION['user'], then session, then calls
264
    * $nextScript by doing a header call.
265
    * 
266
    * @param string $nextScript URL of next script to call
267
    */
4 rodolico 268
   public function logOut( $nextScript = null ) {
269
      $nextScript = $this->getNextScript( $nextScript );
270
      $_SESSION['user'] = null;
271
      session_destroy();
272
      header( "Location: $nextScript" );
273
   }
274
 
7 rodolico 275
   /**
276
    * Simple helper script to calculate next script to call
277
    * 
278
    * Returns one of three URL strings, in order of precedence
279
    * $nextScript
16 rodolico 280
    * $configuration['screens']['validateScript']
7 rodolico 281
    * PHP_SELF
282
    * 
283
    * @param string $nextScript URL to call
284
    * @return string URL
285
    */
16 rodolico 286
   protected function getNextScript( $nextScript = null ) {
4 rodolico 287
      if ( ! isset( $nextScript ) ) {
16 rodolico 288
         $nextScript = $this->configuration['screens']['validateScript'] ?:
4 rodolico 289
                           htmlentities($_SERVER["PHP_SELF"]);
290
      }
291
      return $nextScript;
292
   }
293
 
7 rodolico 294
   /**
295
    * Creates the fields needed for a login screen
296
    * 
297
    * Populates %s's in 'login form' with values for $nextScript and
298
    * 'loginScreen'
299
    * 
300
    * @param string $nextScript URL to call form
301
    * 
302
    * @return string HTML code for display
303
    */
16 rodolico 304
   protected function logInScreen( $nextScript = null ) {
7 rodolico 305
      $return =  sprintf( 
16 rodolico 306
         $this->configuration['screens']['login form'],
4 rodolico 307
         $this->getNextScript( $nextScript ),
16 rodolico 308
         $this->configuration['screens']['loginScreen']
4 rodolico 309
      );
7 rodolico 310
      $return .= $this->errors();
311
      $this->clearErrors();
312
      return $return;
4 rodolico 313
   }
314
 
7 rodolico 315
   /**
316
    * Creates an HTML field for display
317
    * 
318
    * Retrieves the template for the record type, then populates it from
319
    * $record, $value and $field. The template MUST have %s's in the 
320
    * following order for an HTML INPUT field
321
    * label=
322
    * name=
323
    * title=
324
    * placeholder=
325
    * value
326
    * 
327
    * Knows how to handle INPUT types TEXT, TEXTAREA, PASSWORD and 
328
    * special html type boolean, which is checkboxes.
329
    * 
330
    * @param string $field name of the field to populate
16 rodolico 331
    * @param string[] $record Record from $configuration[...][fields]
7 rodolico 332
    * @param string $value the current value to put in INPUT
333
    * 
334
    * @return string An HTML INPUT entity
335
    */
16 rodolico 336
   protected function makeHTMLField ( $field, $record, $value ) {
4 rodolico 337
      $return = array();
16 rodolico 338
      $temp = sprintf( $this->configuration['html input fields'][$record['html type']], 
4 rodolico 339
                        $record['label'] ?: $field,
16 rodolico 340
                        $this->configuration['input prefix'] . $field, 
4 rodolico 341
                        !empty($record['instructions']) ? $record['instructions'] : '',
342
                        !empty($record['hint']) ? $record['hint'] : '',
343
                        $field
344
                     );
345
 
346
      switch ($record['html type'] ) {
347
         case 'text':
348
         case 'textarea':
349
                        $temp = preg_replace( "/~~$field~~/", isset( $value ) ? $value : '', $temp );
350
                        break;
351
         case 'password' :
352
                        break;
353
         case 'boolean' :  // boolean is set by checkboxes
354
                        $temp = preg_replace( "/~~$field~~/", $value ? 'checked' : '', $temp );
355
                        break;
356
      } // case
357
      return $temp;
358
 
359
   } // makeHTMLField
360
 
7 rodolico 361
   /**
362
    * Creates an edit screen for display to user
363
    * 
364
    * This function will create an edit screen which, when displayed to
365
    * the user, allows them to edit a users record. The record is stored
366
    * in $this->workingOn
367
    * 
368
    * Function will go through each field in the users table and call makeHTMLField
369
    * for it, unless the field is restricted and the user is editing their own
370
    * entry. It will also create a hidden input field with the users ID
371
    * 
372
    * NOTE: this will not create the form; the form is created someplace else
373
    * 
374
    * @return string HTML containing all of the INPUT records a user can edit
375
    */
4 rodolico 376
   public function editScreen() {
377
      $return = array();
16 rodolico 378
      $return[] = $this->configuration['screens']['adminScreen'];
4 rodolico 379
      $return[] = "<input type='hidden' name='id' value=" . $this->workingOn['id'] . "'>\n";
16 rodolico 380
      foreach ( $this->configuration['tables']['users']['fields'] as $field => $record ) {
4 rodolico 381
         // if this field is restricted and we are not admin, just skip it
382
         // also skip if it is our record
383
         if ( isset( $record['restrict'] ) && ( $this->data['id'] == $this->workingOn['id'] ) )
384
            continue;
385
         // now process the field
386
         $return[] = $this->makeHTMLField( $field, $record, $this->workingOn[$field] ?? '' );
387
      }
388
      return implode( "\n", $return );
389
   } // editScreen
390
 
7 rodolico 391
   /**
392
    * Creates a variable designed to replace $this->workingOn
393
    * 
394
    * Initializes all fields to something non-null and sets id to -1
395
    * 
396
    * @return string[] An array initialized with all records needed
397
    */
16 rodolico 398
   protected function emptyWorkingOn() {
4 rodolico 399
      $new = array();
400
      $new['id'] = -1;
16 rodolico 401
      foreach ( $this->configuration['tables']['users']['fields'] as $field => $record ) {
4 rodolico 402
         if ( isset( $record['default'] ) ) {
403
            $new[$field] = $record['default'];
404
         } else {
405
            switch ($record['html type']) {
406
               case 'text'    :
407
               case 'blob'    :  $new[$field] = '';
408
                                 break;
409
               case 'boolean' :  $new[$field] = 1;
410
                                 break;
411
               case 'password':  $new[$field] = '';
412
                                 break;
413
            }
414
         } // else
415
      } // foreach
416
      return $new;
417
   }
418
 
7 rodolico 419
   /**
420
    * Sets up the admin function which allows users to edit themselves and, optionally, others
421
    * 
422
    * This should be called the first time, then repeatedly called until it is done
423
    * (it returns the string "Updated", "Failed" or "No changes".
424
    * 
425
    * The first iteration returns an edit screen displaying the users
426
    * information for them to edit. It will display an HTML INPUT for
427
    * each field that is not restricted. The user can then edit the
428
    * chosen entries and press the button, which will call the script
429
    * again, and update the record.
430
    * 
431
    * If the user has the admin right, the Edit screen also displays a 
432
    * list of all users as an unsigned list of anchors. If the user 
433
    * clicks on one of those, it will choose that user, load their data
434
    * and allow the user to edit that users record. NOTE: this is the
435
    * only way to edit fields with the restrict flag set.
436
    * 
437
    * @param  usersDataSource $connection A connection to the data source
438
    * @param string $nextPage The URL of the page to be used in the link
439
    * 
440
    * @return string This may be an HTML table or a single screen
441
    */
4 rodolico 442
   public function admin ( $connection, $nextScript = null ) {
443
      $nextScript = $this->getNextScript( $nextScript );
444
      if ( ! isset($_REQUEST['id']) ) {
445
         // we're working on ourself
446
         $this->workingOn = $this->data;
447
      } elseif ( isset($_REQUEST['id'] ) && $this->workingOn['id'] != $_REQUEST['id'] ) {
448
         // we're working on a different user
449
         if ( $_REQUEST['id'] == -1 ) { // we are adding a new user
450
            $this->workingOn = $this->emptyWorkingOn();
451
         } else { // this is an existing user
452
            $this->workingOn = $connection->getARecord( array( 'id' => $_REQUEST['id'] ) );
453
         }
454
      }
455
      // default to working on ourself
456
      if ( ! ( isset( $this->workingOn ) && count( $this->workingOn ) ) ) {
457
         $this->workingOn = $this->data;
458
      }
459
      // we have no data, so we should create a form for them to enter something
16 rodolico 460
      if ( ! isset( $_REQUEST[$this->configuration['input prefix'] . $this->configuration['tables']['users']['form test']] ) ) {
4 rodolico 461
         // create the screen
462
         $return = $this->editScreen();
463
         if ( $this->data['admin'] ) {
7 rodolico 464
            $return .= $this->allUsersHTML( $connection );
4 rodolico 465
         }
16 rodolico 466
         return sprintf( $this->configuration['screens']['edit form'],
4 rodolico 467
            $nextScript,
468
            $return
469
            );
470
      } else { // we are processing
471
         $data = array();
16 rodolico 472
         foreach ( $this->configuration['tables']['users']['fields'] as $field => $record ) {
4 rodolico 473
            // if this field is restricted it is our record, skip it
474
            if ( isset( $record['restrict'] ) && ( $this->data['id'] == $this->workingOn['id'] ) )
475
               continue;
16 rodolico 476
            $htmlFieldName = $this->configuration['input prefix'] . $field;
4 rodolico 477
            $temp = '';
478
            switch ( $record['html type'] ) {
10 rodolico 479
               case 'password':
480
                  if ( ! empty( $_REQUEST[$htmlFieldName] ) ) {
481
                     $data[$field] = password_hash( $_REQUEST[$htmlFieldName], PASSWORD_DEFAULT );
16 rodolico 482
                     if ( isset( $this->configuration['tables']['users']['fields']['last password change'] ) ) {
10 rodolico 483
                        $data['last password change'] = date("YmdHis");
4 rodolico 484
                     }
7 rodolico 485
                  }
4 rodolico 486
                  break;
487
               case 'boolean' :
488
                  if ( $this->workingOn['id'] == -1 || ( isset( $_REQUEST[$htmlFieldName] ) != $this->workingOn[$field] ) ) {
489
                     $data[$field] = isset( $_REQUEST[$htmlFieldName] ) ? 1 : 0;
490
                  }
491
                  break;
10 rodolico 492
               default : // text, textarea, other things like this
493
                  if ( $this->workingOn['id'] == -1 || ( isset( $_REQUEST[$htmlFieldName] ) && $_REQUEST[$htmlFieldName] !== $this->workingOn[$field] ) ) {
494
                     $data[$field] = $_REQUEST[$htmlFieldName];
495
                     if ( isset( $record['filter'] ) && preg_match( $record['filter'], $data[$field] ) !== 1 ) {
496
                        $this->errors[] = sprintf( "Invalid characters in %s, %s", $record['label'], $record['instructions'] );
497
                        unset( $data[$field] );
498
                     }
499
                  }
500
                  break;
4 rodolico 501
            } // switch
502
         } // foreach
7 rodolico 503
         if ( count($this->errors) ) { // we have some errors
504
            $this->errors[] = 'Record not updated';
505
            return 'Error';
506
         }
4 rodolico 507
         if ( $data ) {
508
            $data['id'] = $this->workingOn['id'];
509
            $return = $connection->update( $data ) ? "Updated" : "Failed";
510
            if ( $this->workingOn['id'] == $this->data['id'] ) // we just updated us, reload record
511
               $this->data = $connection->getARecord( array( 'id' => $this->data['id'] ) );
512
         } else {
513
            $return = "No changes";
514
         }
515
         unset( $this->workingOn );
516
         return $return;
517
      } // else
518
   } // admin
519
 
520
} // class Users
521
 
522
?>