diff --git a/README.md b/README.md index a4f2b11..2f557ab 100644 --- a/README.md +++ b/README.md @@ -38,5 +38,20 @@ Enable it in config: `persistent_login/config.inc.php` $rcmail_config['ifpl_use_auth_tokens'] = true; ``` +### OAuth with login redirect +Persistent Login works with OAuth as-is. However, if Roundcube's automatic login redirect setting `oauth_login_redirect` is true, the user will not have the opportunity to select "Keep me logged in" because Roundcube sends the user directly to the OAuth server for authentication (the login screen is not shown at all). + +To enable Persistent Login in this case, change Roundcube's `oauth_login_redirect` setting to __false__. In config/config.inc.php: +```php +$config["oauth_login_redirect"] = false; +``` + +Then, enable Persistent Login's `ifpl_oauth_login_redirect` setting. In `plugins/persistent_login/config.inc.php` +```php +$rcmail_config['ifpl_oauth_login_redirect'] = true; +``` + +The login screen will be displayed showing only the "Keep me logged in" checkbox and a button to authenticate with the OAuth server. + [roundcube]: http://roundcube.net/ [github-release]: https://github.com/mfreiholz/persistent_login/releases \ No newline at end of file diff --git a/persistent_login.js b/persistent_login.js index ec5bd36..51f9659 100644 --- a/persistent_login.js +++ b/persistent_login.js @@ -18,14 +18,25 @@ $(document).ready(function () { var html = ''; var parentElementSelector = 'form'; var skin = window.rcmail.env.skin; + var hide_login_form = window.rcmail.env.hide_login_form; // Insert different HTML for different skins. if (skin == 'classic' || skin == 'larry') { parentElementSelector = '#login-form form table tbody'; + if (hide_login_form) { + // remove login and password entry rows + $(parentElementSelector).empty(); + // remove login button + $('#login-form .formbuttons').remove(); + // move ifpl checkbox below oauth button + $('p.oauthlogin').after($('#login-form table').detach()); + // left-align checkbox + $('#login-form form table').css({'margin':'0','width':'100%'}); + } html = ` ` + rcmail.gettext('ifpl_rememberme', 'persistent_login') + ` - + @@ -35,6 +46,20 @@ $(document).ready(function () { } else if (skin == 'elastic') { parentElementSelector = '#login-form table tbody'; + if (hide_login_form) { + // remove login and password entry rows + $(parentElementSelector).empty(); + // remove login button + $('#login-form .formbuttons').remove(); + // move ifpl checkbox below oauth button + $('p.oauthlogin').after($('#login-form table').detach()); + // swap oauthlogin container margins with table's + $('p.oauthlogin').addClass('mt-0 mb-0'); + $('#login-form table').css('margin-bottom', '1em'); + // make oauth login button a primary color (was secondary) + $('#rcmloginoauth').addClass('btn-primary'); + } + html = ` @@ -63,6 +88,14 @@ $(document).ready(function () { `; } + // oauth links: add _ifpl cookie when clicking link + $('a#rcmloginoauth').prop('onclick', function() { + return function(evt) { + set_ifpl_cookie($('#_ifpl').prop('checked')) + return true; + }; + }); + // apppend "html" with checkbox to document. var element = $(parentElementSelector); if (element && element.length !== 0) { @@ -86,4 +119,8 @@ $(document).ready(function () { }); } // if (window.rcmail) -}); \ No newline at end of file + + function set_ifpl_cookie(value) { + window.rcmail.set_cookie('_ifpl', value ? "1" : "0"); + } +}); diff --git a/persistent_login.php b/persistent_login.php index ada4683..7fbf69d 100644 --- a/persistent_login.php +++ b/persistent_login.php @@ -1,7 +1,7 @@ cookie_name = $rcmail->config->get('ifpl_cookie_name', '_pt'); $this->use_auth_tokens = $rcmail->config->get('ifpl_use_auth_tokens', false); $this->db_table_auth_tokens = $rcmail->config->get('db_table_auth_tokens', 'auth_tokens'); + $this->oauth_login_redirect = $rcmail->config->get('ifpl_oauth_login_redirect', false); // login form modification hook. $this->add_hook('template_object_loginform', array($this,'persistent_login_loginform')); @@ -76,6 +80,8 @@ function init() $this->add_hook('authenticate', array($this, 'authenticate')); $this->add_hook('login_after', array($this, 'login_after')); $this->add_hook('logout_after', array($this, 'logout_after')); + $this->add_hook('login_failed', array($this, 'login_failed')); + $this->add_hook('oauth_refresh_token', array($this, 'oauth_refresh_token')); } function startup($args) @@ -98,6 +104,8 @@ function startup($args) return $args; } + + function authenticate($args) { $this->authenticate_args = $args; @@ -107,117 +115,136 @@ function authenticate($args) return $args; } - // --- identify user by cookie. ------------------------------------ // - $rcmail = rcmail::get_instance(); - // use token mechanic to identify user. - if ($this->use_auth_tokens) { + $auth = $this->auth_from_cookie(); + if ($auth == null) { + self::unset_persistent_cookie(); + return $args; + } - // remove all expired tokens from database. - $rcmail->get_dbh()->query( - "DELETE FROM " . $rcmail->db->table_name($this->db_table_auth_tokens) - ." WHERE expires < ".$rcmail->db->now()); + $this->authenticate_args['host'] = $auth['host']; + $this->authenticate_args['user'] = $auth['user_name']; - // 0 - user-id - // 1 - auth-token - $token_parts = explode('|', self::get_persistent_cookie()); - - // abort: invalid cookie format. - if (empty($token_parts) || !is_array($token_parts) - || count($token_parts) != 2 - ) { - self::unset_persistent_cookie(); - return $args; - } + $authenticate_success = false; + + if ($auth['auth_type'] == 'PLAIN') { + $authenticate_success = $this->authenticate_plain( + $args, + $auth + ); + } + else if ($auth['auth_type'] == 'OAUTH') { + $authenticate_success = $this->authenticate_oauth( + $args, + $auth + ); + } + + if (! $authenticate_success) { + self::unset_persistent_cookie(); + $args['valid'] = false; + } + return $args; + } - // get auth_token data from db. - $res = $rcmail->get_dbh()->query( - "SELECT * FROM " . $rcmail->db->table_name($this->db_table_auth_tokens) - ." WHERE token = ?" - ." AND user_id = ?", - $token_parts[1], - $token_parts[0]); + /** + * Helper function to authenticate via oauth. The process exits if + * login was successful. Returns false if authentication failed. + * @return bool + */ + function authenticate_oauth(&$args, $auth) { + $rcmail = rcmail::get_instance(); - if (($data = $rcmail->get_dbh()->fetch_assoc($res))) { - // has the token been expired? - /*if (false) { - self::unset_persistent_cookie(); - $rcmail->get_dbh()->query("delete from " . $rcmail->db->table_name('auth_tokens') . " where `token`=? and `user_id`=?", $token_parts[1], $token_parts[0]); - error_log('persistent-login expired, of user ' . $token_parts[0]); - return $args; - }*/ + // refresh the access token + // 'oauth_refresh_token' hook will get called if refreshed + $_SESSION['oauth_token'] = $auth['auth_data']; + $rcmail->oauth->refresh([]); + + $data = $_SESSION['oauth_token']; + $authorization = sprintf( + '%s %s', + $data['token_type'], + $data['access_token'] + ); + + // check XOAUTH2 authorization against the IMAP server + $rcmail->config->set('imap_auth_type', 'XOAUTH2'); + $rcmail->config->set('login_password_maxlen', strlen($authorization)); + $host = empty($auth['host']) ? $rcmail->autoselect_host() : $auth['host']; + if ($rcmail->login($auth['user_name'], $authorization, $host, true)) { + + // log successful login + $rcmail->log_login(); + + // update our cookie + self::set_persistent_cookie(); - // set login data. - $args['user'] = $data['user_name']; - $args['pass'] = $rcmail->decrypt($data['user_pass']); - $args['host'] = $data['host']; - $args['cookiecheck'] = false; - $args['valid'] = true; - $args['abort'] = false; - $this->authenticate_args = $args; - - // remove token from db. - $rcmail->get_dbh()->query( - "DELETE FROM " . $rcmail->db->table_name($this->db_table_auth_tokens) - ." WHERE token = ? " - ." AND user_id = ?", - $token_parts[1], - $token_parts[0]); - } - else { - // seems like the token is invalid. - // this case can only happen if the token is used a 2nd time -> got hacked?! - // for security reason we invalidate all persistent-auth cookies of the user - // and log the wrong users IP! - self::unset_persistent_cookie(); - $rcmail->get_dbh()->query( - "DELETE FROM " . $rcmail->db->table_name($this->db_table_auth_tokens) - . " WHERE user_id = ?", - $token_parts[0]); - //error_log('seems like a persistent login cookie has been stolen. invalidated all auth-tokens of user ' . $token_parts[0]); - } + // update roundcube's sesssion cookies + $rcmail->session->regenerate_id(false); + $rcmail->session->set_auth_cookie(); + // success - redirect to mail + header('Location: ' . $rcmail->url(['task' => 'mail'], true, false)); + exit; } - // use only-cookie mechanic to identify the user. - else { - - // extract user data from auth_token. - // 0 -> user-id - // 1 -> username - // 2 -> password (encrypted) - // 3 -> host - // 4 -> expire timestamp - $plain_token = $rcmail->decrypt($_COOKIE[$this->cookie_name]); - $token_parts = explode('|', $plain_token); + return false; + } - //error_log('plain token from cookie = '.$plain_token); + /** + * Helper function to authenticate via username/password + * @return bool + */ + function authenticate_plain(&$args, $auth) { + // set login data. + $args['user'] = $auth['user_name']; + $args['pass'] = $auth['user_pass']; + $args['host'] = $auth['host']; + $args['cookiecheck'] = false; + $args['valid'] = true; + $args['abort'] = false; + return true; + } - if (!empty($token_parts) && is_array($token_parts) - && count($token_parts) == 5 - ) { - // cookie/token expired. (should never occur, because the browser shall delete the cookie) - if (time() > $token_parts[4]) { - self::unset_persistent_cookie(); - } - // set login data. - else { - $args['user'] = $token_parts[1]; - $args['pass'] = $rcmail->decrypt($token_parts[2]); - $args['host'] = $token_parts[3]; - $args['cookiecheck'] = false; - $args['valid'] = true; - $args['abort'] = false; - $this->authenticate_args = $args; + /** + * roundcube handler for 'oauth_refresh_token' hook, which is + * called after an oauth aceess_token is refreshed. Registered + * roundcube tasks "mail|addressbook|settings" must be set or + * this function won't be called during refreshes when the user is + * on one of those pages. + */ + function oauth_refresh_token($args) { + if (self::is_persistent_cookie_available()) { + $rcmail = rcmail::get_instance(); + + // remove old token when using token mechanic to identify user. + if ($this->use_auth_tokens) { + // 0 - user-id + // 1 - auth-token + $token_parts = explode('|', self::get_persistent_cookie()); + + // abort: invalid cookie format. + if (empty($token_parts) || !is_array($token_parts) + || count($token_parts) != 2 + ) { + return $args; } - } - else { - // invalid token. - self::unset_persistent_cookie(); + + // remove old token + $this->db_remove_token( + $rcmail, + $token_parts[1], // token + $token_parts[0] // user_id + ); } + // update the existing cookie if the user is logged + // in. they won't be logged in if a refresh occurred due + // to our automatically re-authenticating them + if (! empty($rcmail->user->ID)) { + self::set_persistent_cookie(); + } } - return $args; } @@ -231,6 +258,12 @@ function login_after($args) else if (rcube_utils::get_input_value('_ifpl', rcube_utils::INPUT_POST)) { self::set_persistent_cookie(); } + // user just logged in using oauth and wants a cookie now + else if (rcube_utils::get_input_value('_ifpl', rcube_utils::INPUT_COOKIE) == '1') { + self::set_persistent_cookie(); + } + self::remove_cookie('_ifpl'); + // restore the user requested action unless it's an action // that's not compatible with the 'mail' task, which is always // set by roundcube after login @@ -252,11 +285,11 @@ function logout_after($args) && count($token_parts) == 2 ) { // remove token from db. - $rcmail->get_dbh()->query( - "DELETE FROM " . $rcmail->db->table_name($this->db_table_auth_tokens) - . " WHERE token = ? AND user_id = ?", - $token_parts[1], - $token_parts[0]); + $this->db_remove_token( + $rcmail, + $token_parts[1], // token + $token_parts[0] // user_id + ); } } @@ -266,6 +299,15 @@ function logout_after($args) return $args; } + /** + * roundcube handler to clean up if login failed + */ + function login_failed($args) + { + self::unset_persistent_cookie(); + return $args; + } + /////////////////////////////////////////////////////////////////////////// // template callback functions /////////////////////////////////////////////////////////////////////////// @@ -282,6 +324,12 @@ function persistent_login_loginform($content) // the javascript code adds the to the login form. $this->include_script('persistent_login.js'); + // set variable + self::remove_cookie('_ifpl'); + rcmail::get_instance() + ->output + ->set_env('hide_login_form', $this->oauth_login_redirect && rcmail::get_instance()->oauth->is_enabled()); + return $content; } @@ -335,8 +383,19 @@ function set_persistent_cookie() $user_name = $rcmail->user->data['username']; // user password - $user_password = $_SESSION['password']; + $user_password = isset($_SESSION['password']) ? $_SESSION['password'] : ''; + + // auth data + $oauth_data = isset($_SESSION['oauth_token']) ? json_encode($_SESSION['oauth_token']) : null; + if (! empty($oauth_data)) { + $auth_type = 'OAUTH'; + $user_password = ''; + } + else { + $auth_type = 'PLAIN'; + $oauth_data = null; + } if ($this->use_auth_tokens) { // generate new token in database and set it to user as cookie... @@ -351,9 +410,9 @@ function set_persistent_cookie() // insert token to database. $rcmail->get_dbh()->query( "INSERT INTO ".$rcmail->db->table_name($this->db_table_auth_tokens) - ." (token, expires, user_id, user_name, user_pass, host)" - ." VALUES (?, ?, ?, ?, ?, ?)", - $auth_token, $sql_expires, $user_id, $user_name, $user_password, $host); + ." (auth_type, token, expires, user_id, user_name, user_pass, host, auth_data)" + ." VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + $auth_type, $auth_token, $sql_expires, $user_id, $user_name, $user_password, $host, $oauth_data); // set token as cookie. if (!self::set_cookie($this->cookie_name, $crypt_token, $ts_expires)) { @@ -363,7 +422,13 @@ function set_persistent_cookie() else { // create encrypted auth_token to store in cookie. // e.g.: "|||" - $plain_token = $user_id . '|' . $user_name . '|' . $user_password . '|' . $host . '|' . (time() + $this->cookie_expire_time); + if ($auth_type == 'OAUTH') { + $plain_token = 'OAUTH' . '|' . $user_id . '|' . $user_name . '|' . $host . '|' . (time() + $this->cookie_expire_time) . '|' . $oauth_data; + } + else { + $plain_token = 'PLAIN' . '|' . $user_id . '|' . $user_name . '|' . $user_password . '|' . $host . '|' . (time() + $this->cookie_expire_time); + } + $crypt_token = $rcmail->encrypt($plain_token); //error_log('set plain token to cookie = '.$plain_token); @@ -399,6 +464,206 @@ function is_persistent_cookie_available() } } + /** + * retrieve the persistent cookie data as an array or null if auth + * data is unavailable. The fields of the returned associative + * array match those of the database table column names. + * + * returned array: [ + * 'auth_type' => 'OAUTH' | 'PLAIN', + * 'user_id' => {number; may be formatted as integer or string}, + * 'user_name' => {string}, + * 'user_pass' => {string; '' if auth_type=='OAUTH'}, + * 'host' => {string}, + * 'expires' => {string; 'YYYY-MM-DD HH:MM:SS' format if use_auth_tokens is truthy, otherwise the number of seconds from the epoch }, + * 'auth_data' => {array} | null + * ] + * + * when using auth tokens, the function has the side effect of + * removing the correspoding row in the database. + * + * @return array + * @return null + */ + function auth_from_cookie() { + $rcmail = rcmail::get_instance(); + + // use token mechanic to identify user. + if ($this->use_auth_tokens) { + + // purge expired records from the database + $this->clean_db($rcmail); + + // 0 - user-id + // 1 - auth-token + $token_parts = explode('|', self::get_persistent_cookie()); + + // abort: invalid cookie format. + if (empty($token_parts) || !is_array($token_parts) + || count($token_parts) != 2 + ) { + return null; + } + + // get auth_token data from db. + $res = $rcmail->get_dbh()->query( + "SELECT * FROM " . $rcmail->db->table_name($this->db_table_auth_tokens) + ." WHERE token = ?" + ." AND user_id = ?", + $token_parts[1], + $token_parts[0]); + + if (($data = $rcmail->get_dbh()->fetch_assoc($res))) { + $err_msg = null; + if ($data['auth_type'] == 'PLAIN') { + $data['user_pass'] = $rcmail->decrypt($data['user_pass']); + } + else if ($data['auth_type'] == 'OAUTH') { + $data['auth_data'] = json_decode($data['auth_data'], true); + if ($data['auth_data'] == null) { + $err_msg = "Database table auth_tokens contains an auth_data field that is not parsable json. token=" . $data['token']; + $data =null; + } + } + else { + $err_msg = "Database table auth_tokens contains an invalid auth_type. auth_type=" . $data['auth_type'] . " token=" . $data['token']; + $data = null; + } + + $this->db_remove_token( + $rcmail, + $token_parts[1], // token + $token_parts[0] // user_id + ); + + return $data; + } + else { + // seems like the token is invalid. + // this case can only happen if the token is used a 2nd time -> got hacked?! + // for security reason we invalidate all persistent-auth cookies of the user + // and log the wrong users IP! + self::unset_persistent_cookie(); + $this->db_remove_all_user_tokens( + $rcmail, + $token_parts[0] // user_id + ); + //error_log('seems like a persistent login cookie has been stolen. invalidated all auth-tokens of user ' . $token_parts[0]); + return null; + } + + } // end: use_auth_tokens + + + // use only-cookie mechanic to identify the user. + else { + // extract user data from auth_token. + // 0 -> auth_type + // + // for auth_type "PLAIN": + // 1 -> user-id + // 2 -> username + // 3 -> password (encrypted) + // 4 -> host + // 5 -> expire timestamp + // + // for auth_type "OAUTH": + // 1 -> user-id + // 2 -> username + // 3 -> host + // 4 -> expire timestamp + // 5 -> json from oauth server containing authorization + + $plain_token = $rcmail->decrypt($_COOKIE[$this->cookie_name]); + $token_parts = explode('|', $plain_token); + + //error_log('plain token from cookie = '.$plain_token); + + if (empty($token_parts) || !is_array($token_parts) || count($token_parts) < 1) { + // invalid token. + return null; + } + + $auth_type = $token_parts[0]; + + if ($auth_type == 'PLAIN' && count($token_parts) == 6) { + // cookie/token expired. (should never occur, because the browser shall delete the cookie) + if (time() > $token_parts[5]) { + return null; + } + $data = []; + $data['auth_type'] = $auth_type; + $data['user_id'] = $token_parts[1]; + $data['user_name'] = $token_parts[2]; + $data['user_pass'] = $rcmail->decrypt($token_parts[3]); + $data['host'] = $token_parts[4]; + $data['expires'] = $token_parts[5]; + $data['auth_data'] = null; + return $data; + } + else if ($auth_type == 'OAUTH' && count($token_parts) == 6) { + if (time() > $token_parts[4]) { + return null; + } + $data = []; + $data['auth_type'] = $auth_type; + $data['user_id'] = $token_parts[1]; + $data['user_name'] = $token_parts[2]; + $data['user_pass'] = ''; + $data['host'] = $token_parts[3]; + $data['expires'] = $token_parts[4]; + $data['auth_data'] = json_decode($token_parts[5], true); + if ($data['auth_data'] == null) { + // json is invalid + return null; + } + return $data; + } + else { + // invalid auth_type or token. + return null; + } + + + } // end: use only-cookie mechanic to identify the user. + } + + /** + * purge database records with expired tokens + */ + function clean_db($rcmail) { + // remove all expired tokens from database. + $rcmail->get_dbh()->query( + "DELETE FROM " . $rcmail->db->table_name($this->db_table_auth_tokens) + ." WHERE expires < ".$rcmail->db->now()); + } + + /** + * remove the database record corresponding to the supplied + * roundcube user-id and token + */ + function db_remove_token($rcmail, $token, $user_id) { + // remove token from db. + $rcmail->get_dbh()->query( + "DELETE FROM " . $rcmail->db->table_name($this->db_table_auth_tokens) + ." WHERE token = ? " + ." AND user_id = ?", + $token, + $user_id); + } + + /** + * remove all database records corresponding to the supplied + * roundcube user-id + */ + function db_remove_all_user_tokens($rcmail, $user_id) { + $rcmail->get_dbh()->query( + "DELETE FROM " . $rcmail->db->table_name($this->db_table_auth_tokens) + . " WHERE user_id = ?", + $user_id); + } + + /** * generates a random string of numbers and letters. * diff --git a/sql/mysql.sql b/sql/mysql.sql index 1a052aa..0ef55dc 100644 --- a/sql/mysql.sql +++ b/sql/mysql.sql @@ -23,4 +23,10 @@ ALTER TABLE `auth_tokens` -- ALTER TABLE `auth_tokens` - ADD PRIMARY KEY(`token`); \ No newline at end of file + ADD PRIMARY KEY(`token`); + +-- +-- Update version 5.?? +-- +ALTER TABLE `auth_tokens` ADD COLUMN `auth_type` varchar(15) NOT NULL DEFAULT 'PLAIN'; +ALTER TABLE `auth_tokens` ADD COLUMN `auth_data` TEXT NULL; diff --git a/sql/postgres.sql b/sql/postgres.sql index 98bf2b2..a79e352 100644 --- a/sql/postgres.sql +++ b/sql/postgres.sql @@ -15,3 +15,9 @@ CREATE TABLE auth_tokens ( user_pass varchar(128) NOT NULL, host varchar(255) NOT NULL ); + +-- +-- Update version 5.?? +-- +ALTER TABLE auth_tokens ADD COLUMN auth_type varchar(15) NOT NULL DEFAULT 'PLAIN'; +ALTER TABLE auth_tokens ADD COLUMN auth_data TEXT NULL; diff --git a/sql/sqlite.sql b/sql/sqlite.sql index 5bffc9a..33f5cf7 100644 --- a/sql/sqlite.sql +++ b/sql/sqlite.sql @@ -12,3 +12,9 @@ CREATE TABLE IF NOT EXISTS `auth_tokens` ( ); CREATE INDEX IF NOT EXISTS `user_id_fk_auth_tokens` ON `auth_tokens`(`user_id`); + +-- +-- Update version 5.?? +-- +ALTER TABLE `auth_tokens` ADD COLUMN `auth_type` TEXT NOT NULL DEFAULT 'PLAIN'; +ALTER TABLE `auth_tokens` ADD COLUMN `auth_data` TEXT NULL;