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.