Subversion Repositories php_users

Rev

Rev 4 | Rev 10 | 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
   /**
60
    * @var string[] $dbDefinition Contains the configuration for the class
61
    * 
62
    * May be modified by the calling program. Must be replicated in userDataSource class
63
    */
4 rodolico 64
   private $dbDefinition = array(
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',
95
                     'filter'       => '/[a-zA-Z0-9_]/',
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 */
123
   private $data = array();
124
   /** @var string[] $errors Contains errors that can occur */
125
   private $errors = array();
126
   /** @var string[] $workingOn During administration, contains the record being modified */
127
   private $workingOn = array();
128
 
129
   /**
130
    * constructor for an instance of the class
131
    * 
132
    * Anything in $customFields will be recursively merged with $dbDefinition, overwriting
133
    * as necessary.
134
    * 
135
    * @param string[] $customFields array to merge into $dbDefinition
136
    */
4 rodolico 137
   public function __construct( $customFields = array() ) {
138
      if ( $customFields ) {
139
         $this->dbDefinition = array_merge_recursive( $this->dbDefinition, $customFields );
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
207
    * with the values from the database (only those listed in $dbDefinition)
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
    */
216
   private 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'];
221
         foreach ( $this->dbDefinition['tables']['users']['fields'] as $key => $record ) {
222
            if ( $key != 'pass' )
223
               $this->data[$key] = $result[$key];
224
         }
225
      } else {
7 rodolico 226
         $this->errors[] = 'Login Failed: Unknown username or password';
4 rodolico 227
         foreach ( $this->dbDefinition['tables']['users']['fields'] as $key => $record ) {
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
280
    * $dbDefinition['screens']['validateScript']
281
    * PHP_SELF
282
    * 
283
    * @param string $nextScript URL to call
284
    * @return string URL
285
    */
4 rodolico 286
   private function getNextScript( $nextScript = null ) {
287
      if ( ! isset( $nextScript ) ) {
288
         $nextScript = $this->dbDefinition['screens']['validateScript'] ?:
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
    */
304
   private function logInScreen( $nextScript = null ) {
305
      $return =  sprintf( 
4 rodolico 306
         $this->dbDefinition['screens']['login form'],
307
         $this->getNextScript( $nextScript ),
308
         $this->dbDefinition['screens']['loginScreen']
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
331
    * @param string[] $record Record from $dbDefinition[...][fields]
332
    * @param string $value the current value to put in INPUT
333
    * 
334
    * @return string An HTML INPUT entity
335
    */
4 rodolico 336
   private function makeHTMLField ( $field, $record, $value ) {
337
      $return = array();
338
      $temp = sprintf( $this->dbDefinition['html input fields'][$record['html type']], 
339
                        $record['label'] ?: $field,
340
                        $this->dbDefinition['input prefix'] . $field, 
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();
378
      $return[] = $this->dbDefinition['screens']['adminScreen'];
379
      $return[] = "<input type='hidden' name='id' value=" . $this->workingOn['id'] . "'>\n";
380
      foreach ( $this->dbDefinition['tables']['users']['fields'] as $field => $record ) {
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
    */
4 rodolico 398
   private function emptyWorkingOn() {
399
      $new = array();
400
      $new['id'] = -1;
401
      foreach ( $this->dbDefinition['tables']['users']['fields'] as $field => $record ) {
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
460
      if ( ! isset( $_REQUEST[$this->dbDefinition['input prefix'] . $this->dbDefinition['tables']['users']['form test']] ) ) {
461
         // create the screen
462
         $return = $this->editScreen();
463
         if ( $this->data['admin'] ) {
7 rodolico 464
            $return .= $this->allUsersHTML( $connection );
4 rodolico 465
         }
466
         return sprintf( $this->dbDefinition['screens']['edit form'],
467
            $nextScript,
468
            $return
469
            );
470
      } else { // we are processing
471
         $data = array();
472
         foreach ( $this->dbDefinition['tables']['users']['fields'] as $field => $record ) {
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;
476
            $htmlFieldName = $this->dbDefinition['input prefix'] . $field;
477
            $temp = '';
478
            switch ( $record['html type'] ) {
479
               case 'textarea':
480
               case 'text' :
481
                  if ( $this->workingOn['id'] == -1 || ( isset( $_REQUEST[$htmlFieldName] ) && $_REQUEST[$htmlFieldName] !== $this->workingOn[$field] ) ) {
482
                     $data[$field] = $_REQUEST[$htmlFieldName];
7 rodolico 483
                     if ( isset( $record['filter'] ) && preg_filter( $record['filter'], '', $temp ) !== $temp ) {
484
                        $this->errors[] = sprintf( "Invalid characters in %s, %s", $record['label'], $record['instructions'] );
4 rodolico 485
                        $temp = '';
486
                     }
7 rodolico 487
                  }
4 rodolico 488
                  break;
489
               case 'password':
490
                  if ( ! empty( $_REQUEST[$htmlFieldName] ) )
491
                     $data[$field] = password_hash( $_REQUEST[$htmlFieldName], PASSWORD_DEFAULT );
492
                  break;
493
               case 'boolean' :
494
                  if ( $this->workingOn['id'] == -1 || ( isset( $_REQUEST[$htmlFieldName] ) != $this->workingOn[$field] ) ) {
495
                     $data[$field] = isset( $_REQUEST[$htmlFieldName] ) ? 1 : 0;
496
                  }
497
                  break;
498
            } // switch
499
         } // foreach
7 rodolico 500
         if ( count($this->errors) ) { // we have some errors
501
            $this->errors[] = 'Record not updated';
502
            return 'Error';
503
         }
4 rodolico 504
         if ( $data ) {
505
            $data['id'] = $this->workingOn['id'];
506
            $return = $connection->update( $data ) ? "Updated" : "Failed";
507
            if ( $this->workingOn['id'] == $this->data['id'] ) // we just updated us, reload record
508
               $this->data = $connection->getARecord( array( 'id' => $this->data['id'] ) );
509
         } else {
510
            $return = "No changes";
511
         }
512
         unset( $this->workingOn );
513
         return $return;
514
      } // else
515
   } // admin
516
 
517
} // class Users
518
 
519
?>