From 7e553f72fd6bd7f8e229ed21c41676d1192dc9a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Guillot?= Date: Mon, 26 May 2014 20:47:40 -0400 Subject: [PATCH] Add RememberMe authentication --- common.php | 2 + controllers/common.php | 10 +- controllers/user.php | 9 +- locales/fr_FR/translations.php | 2 + models/config.php | 47 ++++- models/remember_me.php | 307 +++++++++++++++++++++++++++++++++ models/schema.php | 18 ++ models/user.php | 11 +- templates/login.php | 2 + 9 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 models/remember_me.php diff --git a/common.php b/common.php index daf62d6..509064a 100644 --- a/common.php +++ b/common.php @@ -23,12 +23,14 @@ require __DIR__.'/models/item.php'; require __DIR__.'/models/schema.php'; require __DIR__.'/models/auto_update.php'; require __DIR__.'/models/database.php'; +require __DIR__.'/models/remember_me.php'; if (file_exists('config.php')) require 'config.php'; defined('APP_VERSION') or define('APP_VERSION', 'master'); defined('HTTP_TIMEOUT') or define('HTTP_TIMEOUT', 20); +defined('BASE_URL_DIRECTORY') or define('BASE_URL_DIRECTORY', dirname($_SERVER['PHP_SELF'])); defined('ROOT_DIRECTORY') or define('ROOT_DIRECTORY', __DIR__); defined('DATA_DIRECTORY') or define('DATA_DIRECTORY', 'data'); diff --git a/controllers/common.php b/controllers/common.php index 3752eac..ef2adfc 100644 --- a/controllers/common.php +++ b/controllers/common.php @@ -9,7 +9,7 @@ use PicoFarad\Template; // Called before each action Router\before(function($action) { - Session\open(dirname($_SERVER['PHP_SELF']), SESSION_SAVE_PATH); + Session\open(BASE_URL_DIRECTORY, SESSION_SAVE_PATH); // Select another database if (! empty($_SESSION['database'])) { @@ -20,7 +20,13 @@ Router\before(function($action) { $ignore_actions = array('login', 'google-auth', 'google-redirect-auth', 'mozilla-auth', 'bookmark-feed', 'select-db'); if (! isset($_SESSION['user']) && ! in_array($action, $ignore_actions)) { - Response\redirect('?action=login'); + + if (! Model\RememberMe\authenticate()) { + Response\redirect('?action=login'); + } + } + else if (Model\RememberMe\has_cookie()) { + Model\RememberMe\refresh(); } // Load translations diff --git a/controllers/user.php b/controllers/user.php index 14154af..03eb646 100644 --- a/controllers/user.php +++ b/controllers/user.php @@ -11,6 +11,7 @@ use PicoFarad\Template; // Logout and destroy session Router\get_action('logout', function() { + Model\RememberMe\destroy(); Session\close(); Response\redirect('?action=login'); }); @@ -18,7 +19,9 @@ Router\get_action('logout', function() { // Display form login Router\get_action('login', function() { - if (isset($_SESSION['user'])) Response\redirect('?action=unread'); + if (isset($_SESSION['user'])) { + Response\redirect('?action=unread'); + } Response\html(Template\load('login', array( 'google_auth_enable' => Model\Config\get('auth_google_token') !== '', @@ -36,7 +39,9 @@ Router\post_action('login', function() { $values = Request\values(); list($valid, $errors) = Model\User\validate_login($values); - if ($valid) Response\redirect('?action=unread'); + if ($valid) { + Response\redirect('?action=unread'); + } Response\html(Template\load('login', array( 'google_auth_enable' => Model\Config\get('auth_google_token') !== '', diff --git a/locales/fr_FR/translations.php b/locales/fr_FR/translations.php index aec30d7..35df327 100644 --- a/locales/fr_FR/translations.php +++ b/locales/fr_FR/translations.php @@ -223,4 +223,6 @@ return array( 'Unable to create the new database.' => 'Impossible créer la nouvelle base de données.', 'Add a new database (new user)' => 'Ajouter une nouvelle base de données (nouvel utilisateur)', 'Create' => 'Créer', + 'Unknown' => 'Inconnu', + 'Remember Me' => 'Connexion automatique', ); diff --git a/models/config.php b/models/config.php index 3b1ec67..0ecd111 100644 --- a/models/config.php +++ b/models/config.php @@ -8,7 +8,7 @@ use PicoDb\Database; use PicoFeed\Config as ReaderConfig; use PicoFeed\Logging; -const DB_VERSION = 24; +const DB_VERSION = 25; const HTTP_USER_AGENT = 'Miniflux (http://miniflux.net)'; // Get PicoFeed config @@ -297,3 +297,48 @@ function save(array $values) return Database::get('db')->table('config')->update($values); } + +// Get the user agent of the connected user +function get_user_agent() +{ + return empty($_SERVER['HTTP_USER_AGENT']) ? t('Unknown') : $_SERVER['HTTP_USER_AGENT']; +} + +// Get the real IP address of the connected user +function get_ip_address($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/models/remember_me.php b/models/remember_me.php new file mode 100644 index 0000000..dbbc01b --- /dev/null +++ b/models/remember_me.php @@ -0,0 +1,307 @@ +table(TABLE) + ->eq('token', $token) + ->eq('sequence', $sequence) + ->gt('expiration', time()) + ->findOne(); +} + +/** + * Get all sessions + * + * @access public + * @return array + */ +function get_all() +{ + return Database::get('db') + ->table(TABLE) + ->desc('date_creation') + ->columns('id', 'ip', 'user_agent', 'date_creation', 'expiration') + ->findAll(); +} + +/** + * Authenticate the user with the cookie + * + * @access public + * @return bool + */ +function authenticate() +{ + $credentials = read_cookie(); + + if ($credentials !== false) { + + $record = find($credentials['token'], $credentials['sequence']); + + if ($record) { + + // Update the sequence + write_cookie( + $record['token'], + update($record['token'], $record['sequence']), + $record['expiration'] + ); + + // Create the session + $_SESSION['user'] = User\get($record['username']); + $_SESSION['config'] = Config\get_all(); + + return true; + } + } + + return false; +} + +/** + * Update the database and the cookie with a new sequence + * + * @access public + */ +function refresh() +{ + $credentials = read_cookie(); + + if ($credentials !== false) { + + $record = find($credentials['token'], $credentials['sequence']); + + if ($record) { + + // Update the sequence + write_cookie( + $record['token'], + update($record['token'], $record['sequence']), + $record['expiration'] + ); + } + } +} + +/** + * Remove a session record + * + * @access public + * @param integer $session_id Session id + * @return mixed + */ +function remove($session_id) +{ + return Database::get('db') + ->table(TABLE) + ->eq('id', $session_id) + ->remove(); +} + +/** + * Remove the current RememberMe session and the cookie + * + * @access public + * @param integer $user_id User id + */ +function destroy() +{ + $credentials = read_cookie(); + + if ($credentials !== false) { + + delete_cookie(); + + Database::get('db') + ->table(TABLE) + ->eq('token', $credentials['token']) + ->remove(); + } +} + +/** + * Create a new RememberMe session + * + * @access public + * @param integer $dbname Database name + * @param integer $username Username + * @param string $ip IP Address + * @param string $user_agent User Agent + * @return array + */ +function create($dbname, $username, $ip, $user_agent) +{ + $token = hash('sha256', $dbname.$username.$user_agent.$ip.Config\generate_token()); + $sequence = Config\generate_token(); + $expiration = time() + EXPIRATION; + + cleanup(); + + Database::get('db') + ->table(TABLE) + ->insert(array( + 'username' => $username, + '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 + * + * @access public + * @return bool + */ +function cleanup() +{ + return Database::get('db') + ->table(TABLE) + ->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 + */ +function update($token, $sequence) +{ + $new_sequence = Config\generate_token(); + + Database::get('db') + ->table(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 + */ +function encode_cookie($token, $sequence) +{ + return implode('|', array(base64_encode(DatabaseModel\select()), $token, $sequence)); +} + +/** + * Decode the value of a cookie + * + * @access public + * @param string $value Raw cookie data + * @return array + */ +function decode_cookie($value) +{ + @list($database, $token, $sequence) = explode('|', $value); + + DatabaseModel\select(base64_decode($database)); + + return array( + 'token' => $token, + 'sequence' => $sequence, + ); +} + +/** + * Return true if the current user has a RememberMe cookie + * + * @access public + * @return bool + */ +function has_cookie() +{ + return ! empty($_COOKIE[COOKIE_NAME]); +} + +/** + * Write and encode the cookie + * + * @access public + * @param string $token Session token + * @param string $sequence Sequence token + * @param string $expiration Cookie expiration + */ +function write_cookie($token, $sequence, $expiration) +{ + setcookie( + COOKIE_NAME, + encode_cookie($token, $sequence), + $expiration, + BASE_URL_DIRECTORY, + null, + ! empty($_SERVER['HTTPS']), + true + ); +} + +/** + * Read and decode the cookie + * + * @access public + * @return mixed + */ +function read_cookie() +{ + if (empty($_COOKIE[COOKIE_NAME])) { + return false; + } + + return decode_cookie($_COOKIE[COOKIE_NAME]); +} + +/** + * Remove the cookie + * + * @access public + */ +function delete_cookie() +{ + setcookie( + COOKIE_NAME, + '', + time() - 3600, + BASE_URL_DIRECTORY, + null, + ! empty($_SERVER['HTTPS']), + true + ); +} diff --git a/models/schema.php b/models/schema.php index 5d1caa2..0e6fdf6 100644 --- a/models/schema.php +++ b/models/schema.php @@ -2,6 +2,24 @@ namespace Schema; + +function version_25($pdo) +{ + $pdo->exec( + 'CREATE TABLE remember_me ( + id INTEGER PRIMARY KEY, + username TEXT, + ip TEXT, + user_agent TEXT, + token TEXT, + sequence TEXT, + expiration INTEGER, + date_creation INTEGER + )' + ); +} + + function version_24($pdo) { $pdo->exec("ALTER TABLE config ADD COLUMN auto_update_url TEXT DEFAULT 'https://github.com/fguillot/miniflux/archive/master.zip'"); diff --git a/models/user.php b/models/user.php index cad2945..f47ce9a 100644 --- a/models/user.php +++ b/models/user.php @@ -5,6 +5,9 @@ namespace Model\User; use SimpleValidator\Validator; use SimpleValidator\Validators; use PicoDb\Database; +use Model\Config; +use Model\RememberMe; +use Model\Database as DatabaseModel; // Get a user by username function get($username) @@ -37,7 +40,13 @@ function validate_login(array $values) unset($user['password']); $_SESSION['user'] = $user; - $_SESSION['config'] = \Model\Config\get_all(); + $_SESSION['config'] = Config\get_all(); + + // Setup the remember me feature + if (! empty($values['remember_me'])) { + $credentials = RememberMe\create(DatabaseModel\select(), $values['username'], Config\get_ip_address(), Config\get_user_agent()); + RememberMe\write_cookie($credentials['token'], $credentials['sequence'], $credentials['expiration']); + } } else { diff --git a/templates/login.php b/templates/login.php index 9d67198..f768844 100644 --- a/templates/login.php +++ b/templates/login.php @@ -36,6 +36,8 @@ +
+