From a04ecbde778decfdea7200806a6b1144861ae05f Mon Sep 17 00:00:00 2001 From: Frédéric Guillot Date: Sat, 19 Apr 2014 22:12:12 -0400 Subject: Add RememberMe feature and authentications history --- assets/css/app.css | 16 +- common.php | 15 +- controllers/action.php | 2 +- controllers/app.php | 11 ++ controllers/base.php | 28 +++- controllers/config.php | 19 ++- controllers/project.php | 90 +++++++++-- controllers/user.php | 13 +- core/session.php | 2 +- locales/es_ES/translations.php | 14 +- locales/fr_FR/translations.php | 14 +- locales/pl_PL/translations.php | 14 +- locales/pt_BR/translations.php | 14 +- models/acl.php | 111 ++++++++++++-- models/action.php | 11 ++ models/base.php | 1 + models/last_login.php | 93 ++++++++++++ models/remember_me.php | 336 +++++++++++++++++++++++++++++++++++++++++ models/user.php | 69 +++++++++ schemas/mysql.php | 32 ++++ schemas/sqlite.php | 31 ++++ templates/config_index.php | 52 ++++++- templates/user_login.php | 2 + tests/AclTest.php | 14 +- 24 files changed, 949 insertions(+), 55 deletions(-) create mode 100644 models/last_login.php create mode 100644 models/remember_me.php diff --git a/assets/css/app.css b/assets/css/app.css index 040c77df..f3a44c92 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -65,6 +65,7 @@ table { width: 100%; border-collapse: collapse; border-spacing: 0; + margin-bottom: 20px; } table caption { @@ -80,7 +81,8 @@ td { border: 1px solid #ccc; padding-top: 0.5em; padding-bottom: 0.5em; - padding-left: 5px; + padding-left: 3px; + padding-right: 3px; } th { @@ -89,13 +91,23 @@ th { } tr:nth-child(odd) td { - background: #fcfcfc; + background: #f8f8f8; } td li { margin-left: 20px; } +.table-small { + font-size: 0.85em; +} + +.table-hover tr:hover td { + border-top: 2px solid #333; + border-bottom: 2px solid #333; + background: rgb(219, 235, 255) +} + /* forms */ form { padding-top: 5px; diff --git a/common.php b/common.php index fd19b8ca..f40c83ae 100644 --- a/common.php +++ b/common.php @@ -6,7 +6,7 @@ require __DIR__.'/core/translator.php'; $registry = new Core\Registry; -$registry->db_version = 11; +$registry->db_version = 12; $registry->db = function() use ($registry) { require __DIR__.'/vendor/PicoDb/Database.php'; @@ -95,6 +95,16 @@ $registry->action = function() use ($registry) { return new \Model\Action($registry->shared('db'), $registry->shared('event')); }; +$registry->rememberMe = function() use ($registry) { + require_once __DIR__.'/models/remember_me.php'; + return new \Model\RememberMe($registry->shared('db'), $registry->shared('event')); +}; + +$registry->lastLogin = function() use ($registry) { + require_once __DIR__.'/models/last_login.php'; + return new \Model\LastLogin($registry->shared('db'), $registry->shared('event')); +}; + if (file_exists('config.php')) require 'config.php'; // Auto-refresh frequency in seconds for the public board view @@ -106,6 +116,9 @@ defined('SESSION_SAVE_PATH') or define('SESSION_SAVE_PATH', ''); // Application version defined('APP_VERSION') or define('APP_VERSION', 'master'); +// Base directory +define('BASE_URL_DIRECTORY', dirname($_SERVER['PHP_SELF'])); + // Database driver: sqlite or mysql defined('DB_DRIVER') or define('DB_DRIVER', 'sqlite'); diff --git a/controllers/action.php b/controllers/action.php index 32ec737d..b4006940 100644 --- a/controllers/action.php +++ b/controllers/action.php @@ -7,7 +7,7 @@ require_once __DIR__.'/base.php'; /** * Automatic actions management * - * @package controllers + * @package controller * @author Frederic Guillot */ class Action extends Base diff --git a/controllers/app.php b/controllers/app.php index e72ac9d0..68872a48 100644 --- a/controllers/app.php +++ b/controllers/app.php @@ -4,8 +4,19 @@ namespace Controller; require_once __DIR__.'/base.php'; +/** + * Application controller + * + * @package controller + * @author Frederic Guillot + */ class App extends Base { + /** + * Redirect to the project creation page or the board controller + * + * @access public + */ public function index() { if ($this->project->countByStatus(\Model\Project::ACTIVE)) { diff --git a/controllers/base.php b/controllers/base.php index cb76cc05..5f482f7e 100644 --- a/controllers/base.php +++ b/controllers/base.php @@ -26,6 +26,8 @@ abstract class Base $this->task = $registry->task; $this->user = $registry->user; $this->comment = $registry->comment; + $this->rememberMe = $registry->rememberMe; + $this->lastLogin = $registry->lastLogin; $this->event = $registry->shared('event'); } @@ -37,7 +39,7 @@ abstract class Base public function beforeAction($controller, $action) { // Start the session - $this->session->open(dirname($_SERVER['PHP_SELF']), SESSION_SAVE_PATH); + $this->session->open(BASE_URL_DIRECTORY, SESSION_SAVE_PATH); // HTTP secure headers $this->response->csp(); @@ -53,9 +55,27 @@ abstract class Base // Set timezone date_default_timezone_set($this->config->get('timezone', 'UTC')); - // If the user is not authenticated redirect to the login form, if the action is public continue - if (! isset($_SESSION['user']) && ! $this->acl->isPublicAction($controller, $action)) { - $this->response->redirect('?controller=user&action=login'); + // Authentication + if (! $this->acl->isLogged() && ! $this->acl->isPublicAction($controller, $action)) { + + // Try the remember me authentication first + if (! $this->rememberMe->authenticate()) { + + // Redirect to the login form if not authenticated + $this->response->redirect('?controller=user&action=login'); + } + else { + + $this->lastLogin->create( + \Model\LastLogin::AUTH_REMEMBER_ME, + $this->acl->getUserId(), + $this->user->getIpAddress(), + $this->user->getUserAgent() + ); + } + } + else if ($this->rememberMe->hasCookie()) { + $this->rememberMe->refresh(); } // Check if the user is allowed to see this page diff --git a/controllers/config.php b/controllers/config.php index 0adf1d54..527c8a4c 100644 --- a/controllers/config.php +++ b/controllers/config.php @@ -28,7 +28,9 @@ class Config extends Base 'errors' => array(), 'menu' => 'config', 'title' => t('Settings'), - 'timezones' => $this->config->getTimezones() + 'timezones' => $this->config->getTimezones(), + 'remember_me_sessions' => $this->rememberMe->getAll($this->acl->getUserId()), + 'last_logins' => $this->lastLogin->getAll($this->acl->getUserId()), ))); } @@ -63,7 +65,9 @@ class Config extends Base 'errors' => $errors, 'menu' => 'config', 'title' => t('Settings'), - 'timezones' => $this->config->getTimezones() + 'timezones' => $this->config->getTimezones(), + 'remember_me_sessions' => $this->rememberMe->getAll($this->acl->getUserId()), + 'last_logins' => $this->lastLogin->getAll($this->acl->getUserId()), ))); } @@ -101,4 +105,15 @@ class Config extends Base $this->session->flash(t('All tokens have been regenerated.')); $this->response->redirect('?controller=config'); } + + /** + * Remove a "RememberMe" token + * + * @access public + */ + public function removeRememberMeToken() + { + $this->rememberMe->remove($this->request->getIntegerParam('id')); + $this->response->redirect('?controller=config&action=index#remember-me'); + } } diff --git a/controllers/project.php b/controllers/project.php index 5cc9f5d1..a89c7879 100644 --- a/controllers/project.php +++ b/controllers/project.php @@ -4,9 +4,19 @@ namespace Controller; require_once __DIR__.'/base.php'; +/** + * Project controller + * + * @package controller + * @author Frederic Guillot + */ class Project extends Base { - // Display access forbidden page + /** + * Display access forbidden page + * + * @access public + */ public function forbidden() { $this->response->html($this->template->layout('project_forbidden', array( @@ -15,7 +25,11 @@ class Project extends Base ))); } - // List of completed tasks for a given project + /** + * List of completed tasks for a given project + * + * @access public + */ public function tasks() { $project_id = $this->request->getIntegerParam('project_id'); @@ -40,7 +54,11 @@ class Project extends Base ))); } - // List of projects + /** + * List of projects + * + * @access public + */ public function index() { $projects = $this->project->getAll(true, $this->acl->isRegularUser()); @@ -54,7 +72,11 @@ class Project extends Base ))); } - // Display a form to create a new project + /** + * Display a form to create a new project + * + * @access public + */ public function create() { $this->response->html($this->template->layout('project_new', array( @@ -65,7 +87,11 @@ class Project extends Base ))); } - // Validate and save a new project + /** + * Validate and save a new project + * + * @access public + */ public function save() { $values = $this->request->getValues(); @@ -90,7 +116,11 @@ class Project extends Base ))); } - // Display a form to edit a project + /** + * Display a form to edit a project + * + * @access public + */ public function edit() { $project = $this->project->getById($this->request->getIntegerParam('project_id')); @@ -108,7 +138,11 @@ class Project extends Base ))); } - // Validate and update a project + /** + * Validate and update a project + * + * @access public + */ public function update() { $values = $this->request->getValues() + array('is_active' => 0); @@ -133,7 +167,11 @@ class Project extends Base ))); } - // Confirmation dialog before to remove a project + /** + * Confirmation dialog before to remove a project + * + * @access public + */ public function confirm() { $project = $this->project->getById($this->request->getIntegerParam('project_id')); @@ -150,7 +188,11 @@ class Project extends Base ))); } - // Remove a project + /** + * Remove a project + * + * @access public + */ public function remove() { $project_id = $this->request->getIntegerParam('project_id'); @@ -164,7 +206,11 @@ class Project extends Base $this->response->redirect('?controller=project'); } - // Enable a project + /** + * Enable a project + * + * @access public + */ public function enable() { $project_id = $this->request->getIntegerParam('project_id'); @@ -178,7 +224,11 @@ class Project extends Base $this->response->redirect('?controller=project'); } - // Disable a project + /** + * Disable a project + * + * @access public + */ public function disable() { $project_id = $this->request->getIntegerParam('project_id'); @@ -192,7 +242,11 @@ class Project extends Base $this->response->redirect('?controller=project'); } - // Users list for the selected project + /** + * Users list for the selected project + * + * @access public + */ public function users() { $project = $this->project->getById($this->request->getIntegerParam('project_id')); @@ -210,7 +264,11 @@ class Project extends Base ))); } - // Allow a specific user for the selected project + /** + * Allow a specific user for the selected project + * + * @access public + */ public function allow() { $values = $this->request->getValues(); @@ -229,7 +287,11 @@ class Project extends Base $this->response->redirect('?controller=project&action=users&project_id='.$values['project_id']); } - // Revoke user access + /** + * Revoke user access + * + * @access public + */ public function revoke() { $values = array( diff --git a/controllers/user.php b/controllers/user.php index bc5c48fe..9e964a4e 100644 --- a/controllers/user.php +++ b/controllers/user.php @@ -32,6 +32,7 @@ class User extends Base */ public function logout() { + $this->rememberMe->destroy($this->acl->getUserId()); $this->session->close(); $this->response->redirect('?controller=user&action=login'); } @@ -63,7 +64,17 @@ class User extends Base $values = $this->request->getValues(); list($valid, $errors) = $this->user->validateLogin($values); - if ($valid) $this->response->redirect('?controller=app'); + if ($valid) { + + $this->lastLogin->create( + \Model\LastLogin::AUTH_DATABASE, + $this->acl->getUserId(), + $this->user->getIpAddress(), + $this->user->getUserAgent() + ); + + $this->response->redirect('?controller=app'); + } $this->response->html($this->template->layout('user_login', array( 'errors' => $errors, diff --git a/core/session.php b/core/session.php index 7fe8e0c1..aa4b2516 100644 --- a/core/session.php +++ b/core/session.php @@ -4,7 +4,7 @@ namespace Core; class Session { - const SESSION_LIFETIME = 2678400; // 31 days + const SESSION_LIFETIME = 86400; // 1 day public function open($base_path = '/', $save_path = '') { diff --git a/locales/es_ES/translations.php b/locales/es_ES/translations.php index 9ae16d20..628e292b 100644 --- a/locales/es_ES/translations.php +++ b/locales/es_ES/translations.php @@ -85,7 +85,7 @@ return array( 'Do you really want to remove this column: "%s"?' => '¿Realmente desea eliminar esta columna : « %s » ?', 'This action will REMOVE ALL TASKS associated to this column!' => '¡Esta acción suprimirá todas las tareas asociadas a esta columna!', 'settings' => 'preferencias', - 'Application Settings' => 'Parámetros de la aplicación', + 'Application settings' => 'Parámetros de la aplicación', 'Language' => 'Idioma', 'Webhooks token:' => 'Identificador (token) para los webhooks :', 'More information' => 'Más informaciones', @@ -94,7 +94,7 @@ return array( 'Optimize the database' => 'Optimisar la base de datos', '(VACUUM command)' => '(Comando VACUUM)', '(Gzip compressed Sqlite file)' => '(Archivo Sqlite comprimido en Gzip)', - 'User Settings' => 'Parámetros de usuario', + 'User settings' => 'Parámetros de usuario', 'My default project:' => 'Mi proyecto por defecto : ', 'Close a task' => 'Cerrar una tarea', 'Do you really want to close this task: "%s"?' => '¿Realmente desea cerrar esta tarea: « %s » ?', @@ -269,4 +269,14 @@ return array( 'Wrong password' => 'contraseña incorrecta', 'Reset all tokens' => 'Reiniciar los identificadores (tokens) de seguridad ', 'All tokens have been regenerated.' => 'Todos los identificadores (tokens) han sido reiniciados.', + // 'Unknown' => '', + // 'Last logins' => '', + // 'Login date' => '', + // 'Authentication method' => '', + // 'IP address' => '', + // 'User agent' => '', + // 'Persistent connections' => '', + // 'No session' => '', + // 'Expiration date' => '', + // 'Remember Me' => '', ); diff --git a/locales/fr_FR/translations.php b/locales/fr_FR/translations.php index 7a7f8d3e..3529582d 100644 --- a/locales/fr_FR/translations.php +++ b/locales/fr_FR/translations.php @@ -85,7 +85,7 @@ return array( 'Do you really want to remove this column: "%s"?' => 'Voulez vraiment supprimer cette colonne : « %s » ?', 'This action will REMOVE ALL TASKS associated to this column!' => 'Cette action va supprimer toutes les tâches associées à cette colonne !', 'settings' => 'préférences', - 'Application Settings' => 'Paramètres de l\'application', + 'Application settings' => 'Paramètres de l\'application', 'Language' => 'Langue', 'Webhooks token:' => 'Jeton de securité pour les webhooks :', 'More information' => 'Plus d\'informations', @@ -94,7 +94,7 @@ return array( 'Optimize the database' => 'Optimiser la base de données', '(VACUUM command)' => '(Commande VACUUM)', '(Gzip compressed Sqlite file)' => '(Fichier Sqlite compressé en Gzip)', - 'User Settings' => 'Paramètres utilisateur', + 'User settings' => 'Paramètres utilisateur', 'My default project:' => 'Mon projet par défaut : ', 'Close a task' => 'Fermer une tâche', 'Do you really want to close this task: "%s"?' => 'Voulez-vous vraiment fermer cettre tâche : « %s » ?', @@ -269,4 +269,14 @@ return array( 'Wrong password' => 'Mauvais mot de passe', 'Reset all tokens' => 'Réinitialiser tous les jetons de sécurité', 'All tokens have been regenerated.' => 'Tous les jetons de sécurité ont été réinitialisés.', + 'Unknown' => 'Inconnu', + 'Last logins' => 'Dernières connexions', + 'Login date' => 'Date de connexion', + 'Authentication method' => 'Méthode d\'authentification', + 'IP address' => 'Adresse IP', + 'User agent' => 'Agent utilisateur', + 'Persistent connections' => 'Connexions persistantes', + 'No session' => 'Aucune session', + 'Expiration date' => 'Date d\'expiration', + 'Remember Me' => 'Connexion automatique', ); diff --git a/locales/pl_PL/translations.php b/locales/pl_PL/translations.php index 718611ac..f11a6d8d 100644 --- a/locales/pl_PL/translations.php +++ b/locales/pl_PL/translations.php @@ -85,7 +85,7 @@ return array( 'Do you really want to remove this column: "%s"?' => 'Na pewno chcesz usunąć kolumnę: "%s"?', 'This action will REMOVE ALL TASKS associated to this column!' => 'Wszystkie zadania w kolumnie zostaną usunięte!', 'settings' => 'ustawienia', - 'Application Settings' => 'Ustawienia aplikacji', + 'Application settings' => 'Ustawienia aplikacji', 'Language' => 'Język', 'Webhooks token:' => 'Token :', 'More information' => 'Więcej informacji', @@ -94,7 +94,7 @@ return array( 'Optimize the database' => 'Optymalizuj bazę danych', '(VACUUM command)' => '(komenda VACUUM)', '(Gzip compressed Sqlite file)' => '(baza danych spakowana Gzip)', - 'User Settings' => 'Ustawienia użytkownika', + 'User settings' => 'Ustawienia użytkownika', 'My default project:' => 'Mój domyślny projekt:', 'Close a task' => 'Zakończ zadanie', 'Do you really want to close this task: "%s"?' => 'Na pewno chcesz zakończyć to zadanie: "%s"?', @@ -274,4 +274,14 @@ return array( 'Wrong password' => 'Błędne hasło', 'Reset all tokens' => 'Zresetuj wszystkie tokeny', 'All tokens have been regenerated.' => 'Wszystkie tokeny zostały zresetowane.', + // 'Unknown' => '', + // 'Last logins' => '', + // 'Login date' => '', + // 'Authentication method' => '', + // 'IP address' => '', + // 'User agent' => '', + // 'Persistent connections' => '', + // 'No session' => '', + // 'Expiration date' => '', + // 'Remember Me' => '', ); diff --git a/locales/pt_BR/translations.php b/locales/pt_BR/translations.php index c5890fa2..b8d80569 100644 --- a/locales/pt_BR/translations.php +++ b/locales/pt_BR/translations.php @@ -85,7 +85,7 @@ return array( 'Do you really want to remove this column: "%s"?' => 'Quer realmente remover esta coluna: "%s"?', 'This action will REMOVE ALL TASKS associated to this column!' => 'Esta ação vai REMOVER TODAS AS TAREFAS associadas a esta coluna!', 'settings' => 'preferências', - 'Application Settings' => 'Preferências da aplicação', + 'Application settings' => 'Preferências da aplicação', 'Language' => 'Idioma', 'Webhooks token:' => 'Token de webhooks:', 'More information' => 'Mais informação', @@ -94,7 +94,7 @@ return array( 'Optimize the database' => 'Otimizar o banco de dados', '(VACUUM command)' => '(Comando VACUUM)', '(Gzip compressed Sqlite file)' => '(Arquivo Sqlite comprimido com Gzip)', - 'User Settings' => 'Configurações do usuário', + 'User settings' => 'Configurações do usuário', 'My default project:' => 'Meu projeto default:', 'Close a task' => 'Encerrar uma tarefa', 'Do you really want to close this task: "%s"?' => 'Quer realmente encerrar esta tarefa: "%s"?', @@ -270,4 +270,14 @@ return array( // 'Wrong password' => '', // 'Reset all tokens' => '', // 'All tokens have been regenerated.' => '', + // 'Unknown' => '', + // 'Last logins' => '', + // 'Login date' => '', + // 'Authentication method' => '', + // 'IP address' => '', + // 'User agent' => '', + // 'Persistent connections' => '', + // 'No session' => '', + // 'Expiration date' => '', + // 'Remember Me' => '', ); diff --git a/models/acl.php b/models/acl.php index ea7dd5cb..c8a39ee4 100644 --- a/models/acl.php +++ b/models/acl.php @@ -4,16 +4,32 @@ namespace Model; require_once __DIR__.'/base.php'; +/** + * Acl model + * + * @package model + * @author Frederic Guillot + */ class Acl extends Base { - // Controllers and actions allowed from outside + /** + * Controllers and actions allowed from outside + * + * @access private + * @var array + */ private $public_actions = array( 'user' => array('login', 'check'), 'task' => array('add'), 'board' => array('readonly'), ); - // Controllers and actions allowed for regular users + /** + * Controllers and actions allowed for regular users + * + * @access private + * @var array + */ private $user_actions = array( 'app' => array('index'), 'board' => array('index', 'show', 'assign', 'assigntask', 'save'), @@ -21,10 +37,18 @@ class Acl extends Base 'task' => array('show', 'create', 'save', 'edit', 'update', 'close', 'confirmclose', 'open', 'confirmopen', 'description', 'duplicate'), 'comment' => array('save', 'confirm', 'remove', 'update', 'edit'), 'user' => array('index', 'edit', 'update', 'forbidden', 'logout', 'index'), - 'config' => array('index'), + 'config' => array('index', 'removeremembermetoken'), ); - // Return true if the specified controller/action is allowed according to the given acl + /** + * Return true if the specified controller/action is allowed according to the given acl + * + * @access public + * @param array $acl Acl list + * @param string $controller Controller name + * @param string $action Action name + * @return bool + */ public function isAllowedAction(array $acl, $controller, $action) { if (isset($acl[$controller])) { @@ -34,37 +58,100 @@ class Acl extends Base return false; } - // Return true if the given action is public + /** + * Return true if the given action is public + * + * @access public + * @param string $controller Controller name + * @param string $action Action name + * @return bool + */ public function isPublicAction($controller, $action) { return $this->isAllowedAction($this->public_actions, $controller, $action); } - // Return true if the given action is allowed for a regular user + /** + * Return true if the given action is allowed for a regular user + * + * @access public + * @param string $controller Controller name + * @param string $action Action name + * @return bool + */ public function isUserAction($controller, $action) { return $this->isAllowedAction($this->user_actions, $controller, $action); } - // Return true if the logged user is admin + /** + * Return true if the logged user is admin + * + * @access public + * @return bool + */ public function isAdminUser() { - return isset($_SESSION['user']['is_admin']) && $_SESSION['user']['is_admin'] === '1'; + return isset($_SESSION['user']['is_admin']) && $_SESSION['user']['is_admin'] === true; } - // Return true if the logged user is not admin + /** + * Return true if the logged user is not admin + * + * @access public + * @return bool + */ public function isRegularUser() { - return isset($_SESSION['user']['is_admin']) && $_SESSION['user']['is_admin'] === '0'; + return isset($_SESSION['user']['is_admin']) && $_SESSION['user']['is_admin'] === false; } - // Get the connected user id + /** + * Get the connected user id + * + * @access public + * @return bool + */ public function getUserId() { return isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : 0; } - // Check if an action is allowed for the logged user + /** + * Check is the user is connected + * + * @access public + * @return bool + */ + public function isLogged() + { + return ! empty($_SESSION['user']); + } + + /** + * Check is the user was authenticated with the RememberMe or set the value + * + * @access public + * @param bool $value Set true if the user use the RememberMe + * @return bool + */ + public function isRememberMe($value = null) + { + if ($value !== null) { + $_SESSION['is_remember_me'] = $value; + } + + return empty($_SESSION['is_remember_me']) ? false : $_SESSION['is_remember_me']; + } + + /** + * Check if an action is allowed for the logged user + * + * @access public + * @param string $controller Controller name + * @param string $action Action name + * @return bool + */ public function isPageAccessAllowed($controller, $action) { return $this->isPublicAction($controller, $action) || diff --git a/models/action.php b/models/action.php index cc8f5cad..a0236eff 100644 --- a/models/action.php +++ b/models/action.php @@ -16,7 +16,18 @@ use \SimpleValidator\Validators; */ class Action extends Base { + /** + * SQL table name for actions + * + * @var string + */ const TABLE = 'actions'; + + /** + * SQL table name for action parameters + * + * @var string + */ const TABLE_PARAMS = 'action_has_params'; /** diff --git a/models/base.php b/models/base.php index 9b5dc67f..70a24321 100644 --- a/models/base.php +++ b/models/base.php @@ -54,6 +54,7 @@ abstract class Base /** * Generate a random token with different methods: openssl or /dev/urandom or fallback to uniqid() * + * @static * @access public * @return string Random token */ diff --git a/models/last_login.php b/models/last_login.php new file mode 100644 index 00000000..96cc6108 --- /dev/null +++ b/models/last_login.php @@ -0,0 +1,93 @@ +db + ->table(self::TABLE) + ->eq('user_id', $user_id) + ->desc('date_creation') + ->findAllByColumn('id'); + + if (count($connections) >= self::NB_LOGINS) { + + $this->db->table(self::TABLE) + ->eq('user_id', $user_id) + ->notin('id', array_slice($connections, 0, self::NB_LOGINS - 1)) + ->remove(); + } + + return $this->db + ->table(self::TABLE) + ->insert(array( + 'auth_type' => $auth_type, + 'user_id' => $user_id, + 'ip' => $ip, + 'user_agent' => $user_agent, + 'date_creation' => time(), + )); + } + + /** + * Get the last connections for a given user + * + * @access public + * @param integer $user_id User id + * @return array + */ + public function getAll($user_id) + { + return $this->db + ->table(self::TABLE) + ->eq('user_id', $user_id) + ->desc('date_creation') + ->columns('id', 'auth_type', 'ip', 'user_agent', 'date_creation') + ->findAll(); + } +} diff --git a/models/remember_me.php b/models/remember_me.php new file mode 100644 index 00000000..2454cc95 --- /dev/null +++ b/models/remember_me.php @@ -0,0 +1,336 @@ +db + ->table(self::TABLE) + ->eq('token', $token) + ->eq('sequence', $sequence) + ->gt('expiration', time()) + ->findOne(); + } + + /** + * Get all sessions for a given user + * + * @access public + * @param integer $user_id User id + * @return array + */ + public function getAll($user_id) + { + return $this->db + ->table(self::TABLE) + ->eq('user_id', $user_id) + ->desc('date_creation') + ->columns('id', 'ip', 'user_agent', 'date_creation', 'expiration') + ->findAll(); + } + + /** + * Authenticate the user with the cookie + * + * @access public + * @return bool + */ + public function authenticate() + { + $credentials = $this->readCookie(); + + if ($credentials !== false) { + + $record = $this->find($credentials['token'], $credentials['sequence']); + + if ($record) { + + // Update the sequence + $this->writeCookie( + $record['token'], + $this->update($record['token'], $record['sequence']), + $record['expiration'] + ); + + // Create the session + $user = new User($this->db, $this->event); + $acl = new Acl($this->db, $this->event); + + $user->updateSession($user->getById($record['user_id'])); + $acl->isRememberMe(true); + + return true; + } + } + + return false; + } + + /** + * Update the database and the cookie with a new sequence + * + * @access public + */ + public function refresh() + { + $credentials = $this->readCookie(); + + if ($credentials !== false) { + + $record = $this->find($credentials['token'], $credentials['sequence']); + + if ($record) { + + // Update the sequence + $this->writeCookie( + $record['token'], + $this->update($record['token'], $record['sequence']), + $record['expiration'] + ); + } + } + } + + /** + * Remove a session record + * + * @access public + * @param integer $session_id Session id + * @return mixed + */ + public function remove($session_id) + { + return $this->db + ->table(self::TABLE) + ->eq('id', $session_id) + ->remove(); + } + + /** + * Remove the current RememberMe session and the cookie + * + * @access public + * @param integer $user_id User id + */ + public function destroy($user_id) + { + $credentials = $this->readCookie(); + + if ($credentials !== false) { + + $this->deleteCookie(); + + $this->db + ->table(self::TABLE) + ->eq('user_id', $user_id) + ->eq('token', $credentials['token']) + ->remove(); + } + } + + /** + * Create a new RememberMe session + * + * @access public + * @param integer $user_id User id + * @param string $ip IP Address + * @param string $user_agent User Agent + * @return array + */ + public function create($user_id, $ip, $user_agent) + { + $token = hash('sha256', $user_id.$user_agent.$ip.$this->generateToken()); + $sequence = $this->generateToken(); + $expiration = time() + self::EXPIRATION; + + $this->cleanup($user_id); + + $this->db + ->table(self::TABLE) + ->insert(array( + 'user_id' => $user_id, + 'ip' => $ip, + 'user_agent' => $user_agent, + 'token' => $token, + 'sequence' => $sequence, + 'expiration' => $expiration, + 'date_creation' => time(), + )); + + return array( + 'token' => $token, + 'sequence' => $sequence, + 'expiration' => $expiration, + ); + } + + /** + * Remove old sessions for a given user + * + * @access public + * @param integer $user_id User id + * @return bool + */ + public function cleanup($user_id) + { + return $this->db + ->table(self::TABLE) + ->eq('user_id', $user_id) + ->lt('expiration', time()) + ->remove(); + } + + /** + * Return a new sequence token and update the database + * + * @access public + * @param string $token Session token + * @param string $sequence Sequence token + * @return string + */ + public function update($token, $sequence) + { + $new_sequence = $this->generateToken(); + + $this->db + ->table(self::TABLE) + ->eq('token', $token) + ->eq('sequence', $sequence) + ->update(array('sequence' => $new_sequence)); + + return $new_sequence; + } + + /** + * Encode the cookie + * + * @access public + * @param string $token Session token + * @param string $sequence Sequence token + * @return string + */ + public function encodeCookie($token, $sequence) + { + return implode('|', array($token, $sequence)); + } + + /** + * Decode the value of a cookie + * + * @access public + * @param string $value Raw cookie data + * @return array + */ + public function decodeCookie($value) + { + list($token, $sequence) = explode('|', $value); + + return array( + 'token' => $token, + 'sequence' => $sequence, + ); + } + + /** + * Return true if the current user has a RememberMe cookie + * + * @access public + * @return bool + */ + public function hasCookie() + { + return ! empty($_COOKIE[self::COOKIE_NAME]); + } + + /** + * Write and encode the cookie + * + * @access public + * @param string $token Session token + * @param string $sequence Sequence token + * @param string $expiration Cookie expiration + */ + public function writeCookie($token, $sequence, $expiration) + { + setcookie( + self::COOKIE_NAME, + $this->encodeCookie($token, $sequence), + $expiration, + BASE_URL_DIRECTORY, + null, + ! empty($_SERVER['HTTPS']), + true + ); + } + + /** + * Read and decode the cookie + * + * @access public + * @return mixed + */ + public function readCookie() + { + if (empty($_COOKIE[self::COOKIE_NAME])) { + return false; + } + + return $this->decodeCookie($_COOKIE[self::COOKIE_NAME]); + } + + /** + * Remove the cookie + * + * @access public + */ + public function deleteCookie() + { + setcookie( + self::COOKIE_NAME, + '', + time() - 3600, + BASE_URL_DIRECTORY, + null, + ! empty($_SERVER['HTTPS']), + true + ); + } +} diff --git a/models/user.php b/models/user.php index 496ae0da..5815b673 100644 --- a/models/user.php +++ b/models/user.php @@ -151,6 +151,10 @@ class User extends Base unset($user['password']); } + $user['id'] = (int) $user['id']; + $user['default_project_id'] = (int) $user['default_project_id']; + $user['is_admin'] = (bool) $user['is_admin']; + $_SESSION['user'] = $user; } @@ -274,7 +278,16 @@ class User extends Base $user = $this->getByUsername($values['username']); if ($user !== false && \password_verify($values['password'], $user['password'])) { + + // Create the user session $this->updateSession($user); + + // Setup the remember me feature + if (! empty($values['remember_me'])) { + $rememberMe = new RememberMe($this->db, $this->event); + $credentials = $rememberMe->create($user['id'], $this->getIpAddress(), $this->getUserAgent()); + $rememberMe->writeCookie($credentials['token'], $credentials['sequence'], $credentials['expiration']); + } } else { $result = false; @@ -287,4 +300,60 @@ class User extends Base $errors ); } + + /** + * Get the user agent of the connected user + * + * @access public + * @return string + */ + public function getUserAgent() + { + return empty($_SERVER['HTTP_USER_AGENT']) ? t('Unknown') : $_SERVER['HTTP_USER_AGENT']; + } + + /** + * Get the real IP address of the connected user + * + * @access public + * @param bool $only_public Return only public IP address + * @return string + */ + public function getIpAddress($only_public = false) + { + $keys = array( + 'HTTP_CLIENT_IP', + 'HTTP_X_FORWARDED_FOR', + 'HTTP_X_FORWARDED', + 'HTTP_X_CLUSTER_CLIENT_IP', + 'HTTP_FORWARDED_FOR', + 'HTTP_FORWARDED', + 'REMOTE_ADDR' + ); + + foreach ($keys as $key) { + + if (isset($_SERVER[$key])) { + + foreach (explode(',', $_SERVER[$key]) as $ip_address) { + + $ip_address = trim($ip_address); + + if ($only_public) { + + // Return only public IP address + if (filter_var($ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false) { + return $ip_address; + } + } + else { + + return $ip_address; + } + } + } + } + + return t('Unknown'); + } } diff --git a/schemas/mysql.php b/schemas/mysql.php index cdd497e3..245232bd 100644 --- a/schemas/mysql.php +++ b/schemas/mysql.php @@ -2,6 +2,38 @@ namespace Schema; +function version_12($pdo) +{ + $pdo->exec(" + CREATE TABLE remember_me ( + id INT NOT NULL AUTO_INCREMENT, + user_id INT, + ip VARCHAR(40), + user_agent VARCHAR(255), + token VARCHAR(255), + sequence VARCHAR(255), + expiration INT, + date_creation INT, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (id) + ) ENGINE=InnoDB CHARSET=utf8" + ); + + $pdo->exec(" + CREATE TABLE last_logins ( + id INT NOT NULL AUTO_INCREMENT, + auth_type VARCHAR(25), + user_id INT, + ip VARCHAR(40), + user_agent VARCHAR(255), + date_creation INT, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (id), + INDEX (user_id) + ) ENGINE=InnoDB CHARSET=utf8" + ); +} + function version_11($pdo) { } diff --git a/schemas/sqlite.php b/schemas/sqlite.php index 26ae09f7..4a69751c 100644 --- a/schemas/sqlite.php +++ b/schemas/sqlite.php @@ -2,6 +2,37 @@ namespace Schema; +function version_12($pdo) +{ + $pdo->exec( + 'CREATE TABLE remember_me ( + id INTEGER PRIMARY KEY, + user_id INTEGER, + ip TEXT, + user_agent TEXT, + token TEXT, + sequence TEXT, + expiration INTEGER, + date_creation INTEGER, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + )' + ); + + $pdo->exec( + 'CREATE TABLE last_logins ( + id INTEGER PRIMARY KEY, + auth_type TEXT, + user_id INTEGER, + ip TEXT, + user_agent TEXT, + date_creation INTEGER, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + )' + ); + + $pdo->exec('CREATE INDEX last_logins_user_idx ON last_logins(user_id)'); +} + function version_11($pdo) { $pdo->exec( diff --git a/templates/config_index.php b/templates/config_index.php index ba971b61..899a4b0c 100644 --- a/templates/config_index.php +++ b/templates/config_index.php @@ -2,7 +2,7 @@
@@ -55,7 +55,7 @@
    @@ -66,4 +66,52 @@
+ + + + + + + + + + + + + + + + + + +
+ + + + +

+ + + + + + + + + + + + + + + + + + +
+
diff --git a/templates/user_login.php b/templates/user_login.php index 0bbd48a1..6a805f14 100644 --- a/templates/user_login.php +++ b/templates/user_login.php @@ -14,6 +14,8 @@ + +
diff --git a/tests/AclTest.php b/tests/AclTest.php index 566d7245..6b392a28 100644 --- a/tests/AclTest.php +++ b/tests/AclTest.php @@ -30,16 +30,16 @@ class AclTest extends Base $_SESSION = array('user' => array()); $this->assertFalse($acl->isAdminUser()); - $_SESSION = array('user' => array('is_admin' => true)); + $_SESSION = array('user' => array('is_admin' => '1')); $this->assertFalse($acl->isAdminUser()); - $_SESSION = array('user' => array('is_admin' => '0')); + $_SESSION = array('user' => array('is_admin' => false)); $this->assertFalse($acl->isAdminUser()); $_SESSION = array('user' => array('is_admin' => '2')); $this->assertFalse($acl->isAdminUser()); - $_SESSION = array('user' => array('is_admin' => '1')); + $_SESSION = array('user' => array('is_admin' => true)); $this->assertTrue($acl->isAdminUser()); } @@ -56,13 +56,13 @@ class AclTest extends Base $_SESSION = array('user' => array('is_admin' => true)); $this->assertFalse($acl->isRegularUser()); - $_SESSION = array('user' => array('is_admin' => '1')); + $_SESSION = array('user' => array('is_admin' => true)); $this->assertFalse($acl->isRegularUser()); $_SESSION = array('user' => array('is_admin' => '2')); $this->assertFalse($acl->isRegularUser()); - $_SESSION = array('user' => array('is_admin' => '0')); + $_SESSION = array('user' => array('is_admin' => false)); $this->assertTrue($acl->isRegularUser()); } @@ -84,7 +84,7 @@ class AclTest extends Base $this->assertTrue($acl->isPageAccessAllowed('board', 'readonly')); // Regular user - $_SESSION = array('user' => array('is_admin' => '0')); + $_SESSION = array('user' => array('is_admin' => false)); $this->assertFalse($acl->isPageAccessAllowed('user', 'create')); $this->assertFalse($acl->isPageAccessAllowed('user', 'save')); $this->assertFalse($acl->isPageAccessAllowed('user', 'remove')); @@ -97,7 +97,7 @@ class AclTest extends Base $this->assertTrue($acl->isPageAccessAllowed('board', 'readonly')); // Admin user - $_SESSION = array('user' => array('is_admin' => '1')); + $_SESSION = array('user' => array('is_admin' => true)); $this->assertTrue($acl->isPageAccessAllowed('user', 'create')); $this->assertTrue($acl->isPageAccessAllowed('user', 'save')); $this->assertTrue($acl->isPageAccessAllowed('user', 'remove')); -- cgit v1.2.3