PHP: gestione degli utenti di base

PHP: gestione degli utenti di base

Un anno fa avevo creato a scopo di test un miniapplicativo in locale per gestire un catalogo di DVD. A questo applicativo avevo poi aggiunto una profilazione utente che consentisse agli utenti di avere una propria pagina in cui postare i loro DVD preferiti scelti dal catalogo, votarli, o lasciare note e commenti per gli altri utenti. Scopo dell'applicativo era quello di testare alcune soluzioni per la profilazione utente, cercando di verificare quali fossero in realtà le best practices più efficaci in tal senso. In questo articolo vedremo alcuni dettagli dell'implementazione che possono rivelarsi utili in un ambiente di produzione (con particolare riguardo, tuttavia, agli errori commessi).

Schema MySQL per il profilo base degli utenti

Lo schema che avevo realizzato riassume i tipi di dati più comuni per una profilazione di base degli utenti:


CREATE TABLE IF NOT EXISTS `utenti` (
  `utenti_id` int(11) NOT NULL AUTO_INCREMENT,
  `utenti_nome` char(64) COLLATE utf8_unicode_ci NOT NULL,
  `utenti_cognome` char(64) COLLATE utf8_unicode_ci NOT NULL,
  `utenti_email` varchar(64) COLLATE utf8_unicode_ci NOT NULL,
  `utenti_password` varchar(36) COLLATE utf8_unicode_ci NOT NULL,
  `utenti_username` varchar(64) COLLATE utf8_unicode_ci NOT NULL,
  `utenti_url` varchar(100) COLLATE utf8_unicode_ci NOT NULL,
  `utenti_account_attivo` tinyint(1) NOT NULL,
  `utenti_ts_registrazione` datetime NOT NULL,
  `utenti_ultimo_login` datetime NOT NULL,
  PRIMARY KEY (`utenti_id`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=1;

Quindi abbiamo:

utenti_id
ID univoco dell'utente.
utenti_nome
Nome dell'utente.
utenti_cognome
Cognome dell'utente.
utenti_email
Email dell'utente.
utenti_password
Password dell'utente codificata in MD5.
utenti_username
Lo username dell'utente per il login.
utenti_url
L'URL della pagina personale dell'utente. Può essere sia SEO-friendly (http://dvd/utenti/utente) che dinamico (http://dvd/?user=utente). L'utente può scegliere quale tipo di URL usare.
utenti_account_attivo
Un flag (1 o 0) per verificare se l'account utente è attivo e, dal lato amministrativo, utile per disabilitarlo all'occorrenza.
utenti_ts_registrazione
Registra la data (completa di ore, minuti e secondi) della registrazione dell'utente.
utenti_ultimo_login
Qui probabilmente si dovrebbe usare un tipo di dati timestamp per registrare l'ultimo login dell'utente.

Di alcune scelte a livello di database management non sono affatto convinto ancora oggi, anche perchè questo tipo di profilazione è incentrata su persone fisiche. In realtà occorrerebbe operare un'astrazione maggiore sul database per contemplare anche il caso in cui a registrarsi siano enti o società.

Struttura degli URL

Gli URL dell'applicativo vengono gestiti da PHP con l'ausilio del seguente file .htaccess posizionato nella document root:

RewriteEngine on
RewriteCond %{SCRIPT_FILENAME} !-f
RewriteCond %{SCRIPT_FILENAME} !-d
RewriteRule "^(.*)$" "index.php/$1"

Tutte le richieste vengono reindirizzate al solo file index.php, tranne nel caso in cui il file o la directory richiesta esista fisicamente sul sito.

Codice PHP

Il codice PHP utilizzato non fa uso del pattern MVC. La scelta era dovuta al fatto che all'epoca avevo un tremendo bisogno di definire fin nei più piccoli dettagli il codice per capire, passo dopo passo, tutte le varie procedure alla base del semplice applicativo che stavo realizzando. Il mio scopo era quello di testare e di scrivere molto codice per essere sicuro della validità di certe mie idee, in parte acquisite e in parte personali.

La classe direttamente responsabile della gestione degli utenti era chiamata DVD_User_Handler. Aveva i seguenti metodi:

DVD_User_Handler::loginUser()

Metodo pubblico che prima valida il login dell'utente e quindi restituisce un output a seconda dell'esito della validazione:


 public function loginUser() 
 {
          
          $err = $this->validateUserLogin();
          
          if(count($err) == 0) {
              
             $user = $_POST['username'];
             $now = time();
             $last_login = $this->formatDate($now);
             
             $result = $this->simpleQuery("UPDATE utenti SET utenti_ultimo_login = 
             '$last_login' WHERE utenti_username = '$user'");
             
             if($result) {
                 
                 $urls = $this->query("SELECT utenti_url FROM utenti WHERE utenti_username = '$user'");
                 
                 foreach($urls as $url) {
                     
                     $user_url = $url['utenti_url'];
                     
                 }
                 
                 
                 
                 include('i/login-success.php');
                   
                 
             } else {
                 
                 
                 include('i/login-failure.php');
             } 
              
          } else {
             
 
             include('i/login-errors.php'); 
              
          }
          
          
}

L'errore di fondo di questo metodo, e più in generale di tutta la classe, è quello di eseguire due tipi di azioni che nel modello MVC sono affidate a due componenti diversi. Effettuare il login di un utente registrato e quindi visualizzare un template HTML e popolarlo con le variabili estratte da una query sono due compiti che devono rimanere separati.

DVD_User_Handler::registerUser()

Questo metodo pubblico valida i dati dell'utente e quindi registra tali dati ma non attiva l'account. Per attivare l'account l'utente dovrà seguire il link inviato per email:



      public function registerUser() 
      {
          
          $errs = $this->validate();
          
          if(count($errs) == 0) {
              
              $name = $_POST['name'];
              $surname = $_POST['surname'];
              $email = $_POST['mail'];
              $username = $_POST['username'];
              $fullname = $name . ' ' . $surname;
              $url = $this->createUserURL($username);
              $now = time();
              $ts_subscribe = $this->formatDate($now);
              $password = md5($_POST['pwd']);
              
              $values = "'', '$name', '$surname', '$fullname',
                         '$email', '$password',
                         '$username', '$url', 0, '$ts_subscribe', '$ts_subscribe'";
              
              $query = $this->insert('utenti', $values);
              
              if($query) {
                  
                  $this->sendActivationEmail();
                  
                  include('i/subscribe-success.php');
                  
              } else {
                  
                  echo '<p>Query failed.</p>' . "\n";
                  
              }
          } else {
              
              include('i/subscribe-validation-errors.php');
              
              $this->showErrors($errs);
              $this->displayRegisterForm();
              
          }
          
          
          
}

Questo metodo utilizza a sua volta i metodi createUserURL() e sendActivationEmail(). Il primo è come segue:


public function createUserURL($username) 
{ 
          
          if(isset($_POST['yes'])) {
              
              
              
              $url = DVD_HTML::BASE_URL . 'utenti/' . $username;
              
              
              
          } else {
              
             
             $url = DVD_HTML::BASE_URL . '?user=' . $username; 
              
              
          }
          
          return $url;
          
          
          
}

Tale metodo si basa sulla scelta dell'utente di volere o meno un URL SEO-friendly. Il secondo metodo si basa sulla classe DVD_Email:


public function sendActivationEmail() 
{
          $link = $this->createActivationLink();
          $mail = new DVD_Email('mail');
          $body = 'Gentile ' . $_POST['name'] . ' ' . $_POST['surname'] . ',' . "\n";
          $body .= 'grazie per la tua registrazione.' . "\n";
          $body .= 'Per attivare il tuo account segui il link in basso:' . "\n";
          $body .= $link;
          $mail->setBody($body);
          
          $mail->sendConfirmEmail(); 
          
          
}

A sua volta tale metodo usa il metodo createActivationLink() per generare il link di attivazione:


public function createActivationLink() 
{
          
          $results = $this->query('SELECT utenti_id FROM utenti WHERE utenti_id = last_insert_id(utenti_id)');
          
          if(count($results)) {
              
              
              foreach($results as $result) {
                  
                  $id = $result['utenti_id'];
                  
              }
              
              $url = DVD_HTML::BASE_URL . '?user=' . $id . '&action=confirm';
              
              return $url;
              
              
          } else {
              
              return false;
              
          }
          
          
          
}

C'è un grave errore in questo metodo: all'URL non viene aggiunto nessun tipo di salt o hash random e crittato per prevenire alcuni problemi di sicurezza che possono sorgere creando un URL del tipo http://dvd/?user=1&action=confirm. In realtà occorrerebbe generare tale hash o salt e usarlo nell'URL generato.

DVD_User_Handler::activateAccount()

Questo metodo pubblico verifica in primis che il link di attivazione non sia scaduto e quindi imposta il campo utenti_account_attivo su 1:


public function activateAccount() 
{
          
          if(!$this->isActivationExpired()) {
              
               $user_data = $this->parseUserURL();
               $id = $user_data['id'];       
              
              $results = $this->query("SELECT utenti_id, utenti_account_attivo FROM
                                       utenti WHERE utenti_id = $id");
                                       
              foreach($results as $result) {
                  
                  $is_active = $result['utenti_account_attivo'];
              
              }
              
              if(0 = $is_active) {
                  
                  $result = $this->simpleQuery("UPDATE utenti SET utenti_account_attivo = 1 WHERE utenti_id = $id");
                  
                  if($result) {
                      
                      include('i/subscribe-activated-account.php');
                      
                      
                  } else {
                      
                      
                      include('i/subscribe-server-error.php');
                      
                  }
                  
                  
                  
              } else {
                  
                  
                     include('i/subscribe-account-already-active.php');
                  
                  
                  
              }
              
              
              
          } else {
              
              
              include('i/subscribe-expired-link.php');
              
              
          }
          
          
         
}

Questo metodo utilizza a sua volta il metodo isActivationExpired():


public function isActivationExpired() 
{
          
          $now = time();
          $results = $this->query("SELECT utenti_id, utenti_ts_registrazione, utenti_account_attivo 
          FROM utenti WHERE utenti_id = last_insert_id(utenti_id) AND utenti_account_attivo = 0");
          
          if(count($results)) 
          {
              
              foreach($results as $result) {
                  
                  $registration = $result['utenti_ts_registrazione'];
                  
              }
              
              $ts_registration = strtotime($registration);
              
              if($now - $ts_registration > 864000) {
                  
                  
                  return true;
                  
                  
              } else {
                  
                  return false;
                  
              }
              
              
              
              
          } 
          
          
}

Ecco come la nostra scelta di utilizzare il tipo dati datetime per memorizzare la data e l'ora della registrazione dell'utente si rivela inefficace: siamo costretti ad utilizzare la funzione strtotime() per convertire il dato datetime in un timestamp Unix. Sarebbe stato meglio utilizzare direttamente quest'ultimo tipo di dati.

Gli ultimi due metodi che analizzeremo riguardano la gestione delle password degli utenti:


public function recoverPassword() 
{
          
          $err = $this->validateRecoveryPasswordForm();
          
          if(count($err) == 0) {
              
              
              $password_email = $_POST['pwd-email'];
              $new_password = $this->generateNewPassword();
              $db_password = md5($new_password);
              
              $update = $this->simpleQuery("UPDATE utenti SET utenti_password = '$db_password' 
                                            WHERE utenti_email = '$password_email'");
              
              
              if($update) {
                  
                  $email = new DVD_Email('pwd-email');
                  $email_body = 'Ecco la tua nuova password:' . "\n\n";
                  $email_body .= $new_password;
                  $email->setBody($email_body);
                  $email->sendChangePasswordEmail();
                  
                  include('i/password-recovery-success.php');
                  
              } else {
                  
                  
                  include('i/password-recovery-failure.php');
                  
              }
              
              
          } else {
              
              
              include('i/password-recovery-errors.php');
              
              
          }
          
          
}

public function generateNewPassword() 
{
          
          $password = DVD_Common::createRandomString();
          return $password;
          
          
}

Dato che la password è codificata in MD5, dobbiamo rigenerarla nuovamente. Il metodo createRandomString() della classe helper DVD_Common fa proprio questo, generando una stringa random:


public function createRandomString($length = 8) 
{
          
          
          $string = '';
          $possible = '0123456789bcdfghjkmnpqrstvwxyz';
          $i = 0;
          
          while($i < $length) {
              
              $char = substr($possible, mt_rand(0, strlen($possible)-1), 1);
              
              if (!strstr($password, $char)) { 
                  $string .= $char;
                  $i++;
              }
              
          }
          
          return $string;
          
          
          
}

Ecco un nuovo errore: la stringa di partenza è costituita da soli caratteri alfanumerici. Sarebbe opportuno introdurre anche caratteri speciali e aumentare la lunghezza predefinita per renderla più sicura.

Conclusione

Molti errori sono stati commessi nell'implementazione appena vista, che si rivela rozza ed incompleta. Tuttavia, dal codice di cui sopra emergono alcuni passaggi fondamentali:

  • registrazione dell'utente
    • invio del link di attivazione
  • attivazione dell'account
  • impostazione dell'URL della pagina utente
  • gestione delle password

In ambiente di produzione, si consiglia vivamente l'uso del pattern MVC o di un framework basato su esso per la gestione dei passaggi appena elencati.

Torna su