Added customizable parameters for session length and the long-missing "remember me" option (or rather, the ability to turn it off and make sessions temporary)
authorDan
Tue, 12 Aug 2008 00:06:35 -0400
changeset 688 f2a824ce5f18
parent 687 ea43ac1ff2ee
child 689 13f8383a7538
Added customizable parameters for session length and the long-missing "remember me" option (or rather, the ability to turn it off and make sessions temporary)
includes/clientside/static/login.js
includes/constants.php
includes/sessions.php
install/schemas/upgrade/1.1.4-1.1.5-mysql.sql
install/schemas/upgrade/1.1.4-1.1.5-postgresql.sql
language/english/admin.json
language/english/core.json
language/english/user.json
plugins/SpecialAdmin.php
plugins/SpecialUserFuncs.php
--- a/includes/clientside/static/login.js	Tue Aug 12 00:05:09 2008 -0400
+++ b/includes/clientside/static/login.js	Tue Aug 12 00:06:35 2008 -0400
@@ -524,6 +524,59 @@
   // Done building the main part of the form
   form.appendChild(table);
   
+  // Field: remember login
+  if ( logindata.user_level <= USER_LEVEL_MEMBER )
+  {
+    var lbl_remember = document.createElement('label');
+    lbl_remember.style.fontSize = 'smaller';
+    lbl_remember.style.display = 'block';
+    lbl_remember.style.textAlign = 'center';
+    
+    // figure out what text to put in the "remember me" checkbox
+    // infinite session length?
+    if ( data.extended_time == 0 )
+    {
+      // yes, infinite
+      var txt_remember = $lang.get('user_login_ajax_check_remember_infinite');
+    }
+    else
+    {
+      if ( data.extended_time % 7 == 0 )
+      {
+        // number of days is a multiple of 7
+        // use weeks as our unit
+        var sess_time = data.extended_time / 7;
+        var unit = 'week';
+      }
+      else
+      {
+        // use days as our unit
+        var sess_time = data.extended_time;
+        var unit = 'day';
+      }
+      // more than one week or day?
+      if ( sess_time != 1 )
+        unit += 's';
+      
+      // assemble the string
+      var txt_remember = $lang.get('user_login_ajax_check_remember', {
+          session_length: sess_time,
+          length_units: $lang.get('etc_unit_' + unit)
+        });
+    }
+    var check_remember = document.createElement('input');
+    check_remember.type = 'checkbox';
+    // this onclick attribute changes the cookie whenever the checkbox or label is clicked
+    check_remember.setAttribute('onclick', 'var ck = ( this.checked ) ? "enable" : "disable"; createCookie("login_remember", ck, 3650);');
+    if ( readCookie('login_remember') != 'disable' )
+      check_remember.setAttribute('checked', 'checked');
+    check_remember.id = 'ajax_login_field_remember';
+    lbl_remember.appendChild(check_remember);
+    lbl_remember.innerHTML += ' ' + txt_remember;
+    
+    form.appendChild(lbl_remember);
+  }
+  
   // Field: enable Diffie Hellman
   if ( IE || is_iPhone )
   {
@@ -626,7 +679,7 @@
   }
 }
 
-window.ajaxLoginSubmitForm = function(real, username, password, captcha)
+window.ajaxLoginSubmitForm = function(real, username, password, captcha, remember)
 {
   // Perform AES test to make sure it's all working
   if ( !aes_self_test() )
@@ -648,6 +701,22 @@
         d.parentNode.removeChild(d);
       }, to);
   }
+  // "Remember session" switch
+  if ( typeof(remember) == 'boolean' )
+  {
+    var remember_session = remember;
+  }
+  else
+  {
+    if ( document.getElementById('ajax_login_field_remember') )
+    {
+      var remember_session = ( document.getElementById('ajax_login_field_remember').checked ) ? true : false;
+    }
+    else
+    {
+      var remember_session = false;
+    }
+  }
   // Encryption: preprocessor
   if ( real )
   {
@@ -695,7 +764,7 @@
       // Wait while the browser updates the login window
       setTimeout(function()
         {
-          ajaxLoginSubmitForm(true, username, password, captcha);
+          ajaxLoginSubmitForm(true, username, password, captcha, remember_session);
         }, 200);
       return true;
     }
@@ -750,7 +819,8 @@
       dh_public_key: logindata.key_dh,
       dh_client_key: dh_pub,
       dh_secret_hash: secret_hash,
-      level: logindata.user_level
+      level: logindata.user_level,
+      remember: remember_session
     }
   }
   else
@@ -761,7 +831,8 @@
       captcha_code: captcha_code,
       captcha_hash: captcha_hash,
       key_aes: hex_md5(crypt_key),
-      level: logindata.user_level
+      level: logindata.user_level,
+      remember: remember_session
     }
   }
   ajaxLoginPerformRequest(json_packet);
--- a/includes/constants.php	Tue Aug 12 00:05:09 2008 -0400
+++ b/includes/constants.php	Tue Aug 12 00:06:35 2008 -0400
@@ -68,6 +68,15 @@
 define('PAGE_GRP_NORMAL', 3);
 define('PAGE_GRP_REGEX', 4);
 
+// Session key types
+// Short keys last for getConfig('session_short_time', '720'); in minutes and auto-renew.
+// Long keys last for getConfig('session_remember_time', '30'); in days and do NOT auto-renew.
+// Elevated keys have a hard-coded 15-minute limit for security reasons and because
+// that's how Enano's done it since before beta 1.
+define('SK_SHORT', 0);
+define('SK_LONG', 1);
+define('SK_ELEV', 2);
+
 // Identifier for the default pseudo-language
 define('LANG_DEFAULT', 0);
 
--- a/includes/sessions.php	Tue Aug 12 00:05:09 2008 -0400
+++ b/includes/sessions.php	Tue Aug 12 00:06:35 2008 -0400
@@ -563,57 +563,17 @@
    * @param string $aes_key The MD5 hash of the encryption key, hex-encoded
    * @param string $challenge The 256-bit MD5 challenge string - first 128 bits should be the hash, the last 128 should be the challenge salt
    * @param int $level The privilege level we're authenticating for, defaults to 0
-   * @param array $captcha_hash Optional. If we're locked out and the lockout policy is captcha, this should be the identifier for the code.
-   * @param array $captcha_code Optional. If we're locked out and the lockout policy is captcha, this should be the code the user entered.
+   * @param string $captcha_hash Optional. If we're locked out and the lockout policy is captcha, this should be the identifier for the code.
+   * @param string $captcha_code Optional. If we're locked out and the lockout policy is captcha, this should be the code the user entered.
+   * @param bool $remember Optional. If true, remembers the session for X days. Otherwise, assigns a short session. Defaults to false.
    * @param bool $lookup_key Optional. If true (default) this queries the database for the "real" encryption key. Else, uses what is given.
    * @return string 'success' on success, or error string on failure
    */
    
-  function login_with_crypto($username, $aes_data, $aes_key_id, $challenge, $level = USER_LEVEL_MEMBER, $captcha_hash = false, $captcha_code = false, $lookup_key = true)
+  function login_with_crypto($username, $aes_data, $aes_key_id, $challenge, $level = USER_LEVEL_MEMBER, $captcha_hash = false, $captcha_code = false, $remember = false, $lookup_key = true)
   {
     global $db, $session, $paths, $template, $plugins; // Common objects
     
-    $privcache = $this->private_key;
-
-    if ( !defined('IN_ENANO_INSTALL') )
-    {
-      $timestamp_cutoff = time() - $duration;
-      $q = $this->sql('SELECT timestamp FROM '.table_prefix.'lockout WHERE timestamp > ' . $timestamp_cutoff . ' AND ipaddr = \'' . $ipaddr . '\' ORDER BY timestamp DESC;');
-      $fails = $db->numrows();
-      // Lockout stuff
-      $threshold = ( $_ = getConfig('lockout_threshold') ) ? intval($_) : 5;
-      $duration  = ( $_ = getConfig('lockout_duration') ) ? intval($_) : 15;
-      // convert to minutes
-      $duration  = $duration * 60;
-      $policy = ( $x = getConfig('lockout_policy') && in_array(getConfig('lockout_policy'), array('lockout', 'disable', 'captcha')) ) ? getConfig('lockout_policy') : 'lockout';
-      if ( $policy == 'captcha' && $captcha_hash && $captcha_code )
-      {
-        // policy is captcha -- check if it's correct, and if so, bypass lockout check
-        $real_code = $this->get_captcha($captcha_hash);
-      }
-      if ( $policy != 'disable' && !( $policy == 'captcha' && isset($real_code) && strtolower($real_code) == strtolower($captcha_code) ) )
-      {
-        $ipaddr = $db->escape($_SERVER['REMOTE_ADDR']);
-        if ( $fails >= $threshold )
-        {
-          // ooh boy, somebody's in trouble ;-)
-          $row = $db->fetchrow();
-          $db->free_result();
-          return array(
-              'success' => false,
-              'error' => 'locked_out',
-              'lockout_threshold' => $threshold,
-              'lockout_duration' => ( $duration / 60 ),
-              'lockout_fails' => $fails,
-              'lockout_policy' => $policy,
-              'time_rem' => ( $duration / 60 ) - round( ( time() - $row['timestamp'] ) / 60 ),
-              'lockout_last_time' => $row['timestamp']
-            );
-        }
-      }
-      $db->free_result();
-    }
-    
     // Instanciate the Rijndael encryption object
     $aes = AESCrypt::singleton(AES_BITS, AES_BLOCKSIZE);
     
@@ -656,163 +616,8 @@
     // Decrypt our password
     $password = $aes->decrypt($aes_data, $bin_key, ENC_HEX);
     
-    // Initialize our success switch
-    $success = false;
-    
-    // Escaped username
-    $username = str_replace('_', ' ', $username);
-    $db_username_lower = $this->prepare_text(strtolower($username));
-    $db_username       = $this->prepare_text($username);
-    
-    // Select the user data from the table, and decrypt that so we can verify the password
-    $this->sql('SELECT password,old_encryption,user_id,user_level,theme,style,temp_password,temp_password_time FROM '.table_prefix.'users WHERE ' . ENANO_SQLFUNC_LOWERCASE . '(username)=\''.$db_username_lower.'\' OR username=\'' . $db_username . '\';');
-    if($db->numrows() < 1)
-    {
-      // This wasn't logged in <1.0.2, dunno how it slipped through
-      if($level > USER_LEVEL_MEMBER)
-        $this->sql('INSERT INTO '.table_prefix.'logs(log_type,action,time_id,date_string,author,edit_summary,page_text) VALUES(\'security\', \'admin_auth_bad\', '.time().', \''.enano_date('d M Y h:i a').'\', \''.$db->escape($username).'\', \''.$db->escape($_SERVER['REMOTE_ADDR']).'\', ' . intval($level) . ')');
-      else
-        $this->sql('INSERT INTO '.table_prefix.'logs(log_type,action,time_id,date_string,author,edit_summary) VALUES(\'security\', \'auth_bad\', '.time().', \''.enano_date('d M Y h:i a').'\', \''.$db->escape($username).'\', \''.$db->escape($_SERVER['REMOTE_ADDR']).'\')');
-    
-      if ( $policy != 'disable' && !defined('IN_ENANO_INSTALL') )
-      {
-        $ipaddr = $db->escape($_SERVER['REMOTE_ADDR']);
-        // increment fail count
-        $this->sql('INSERT INTO '.table_prefix.'lockout(ipaddr, timestamp, action) VALUES(\'' . $ipaddr . '\', ' . time() . ', \'credential\');');
-        $fails++;
-        // ooh boy, somebody's in trouble ;-)
-        return array(
-            'success' => false,
-            'error' => ( $fails >= $threshold ) ? 'locked_out' : 'invalid_credentials',
-            'lockout_threshold' => $threshold,
-            'lockout_duration' => ( $duration / 60 ),
-            'lockout_fails' => $fails,
-            'time_rem' => ( $duration / 60 ),
-            'lockout_policy' => $policy
-          );
-      }
-      
-      return array(
-          'success' => false,
-          'error' => 'invalid_credentials'
-        );
-    }
-    $row = $db->fetchrow();
-    
-    // Check to see if we're logging in using a temporary password
-    
-    if((intval($row['temp_password_time']) + 3600*24) > time() )
-    {
-      $temp_pass = $aes->decrypt( $row['temp_password'], $this->private_key, ENC_HEX );
-      if( $temp_pass == $password )
-      {
-        $url = makeUrlComplete('Special', 'PasswordReset/stage2/' . $row['user_id'] . '/' . $row['temp_password']);
-        
-        $code = $plugins->setHook('login_password_reset');
-        foreach ( $code as $cmd )
-        {
-          eval($cmd);
-        }
-        
-        redirect($url, '', '', 0);
-        exit;
-      }
-    }
-    
-    if($row['old_encryption'] == 1)
-    {
-      // The user's password is stored using the obsolete and insecure MD5 algorithm, so we'll update the field with the new password
-      if(md5($password) == $row['password'])
-      {
-        $pass_stashed = $aes->encrypt($password, $this->private_key, ENC_HEX);
-        $this->sql('UPDATE '.table_prefix.'users SET password=\''.$pass_stashed.'\',old_encryption=0 WHERE user_id='.$row['user_id'].';');
-        $success = true;
-      }
-    }
-    else
-    {
-      // Our password field is up-to-date with the >=1.0RC1 encryption standards, so decrypt the password in the table and see if we have a match; if so then do challenge authentication
-      $real_pass = $aes->decrypt(hexdecode($row['password']), $this->private_key, ENC_BINARY);
-      if($password === $real_pass && is_string($password))
-      {
-        // Yay! We passed AES authentication. Previously an MD5 challenge was done here, this was deemed redundant in 1.1.3.
-        // It didn't seem to provide any additional security...
-        $success = true;
-      }
-    }
-    if($success)
-    {
-      if($level > $row['user_level'])
-        return array(
-          'success' => false,
-          'error' => 'too_big_for_britches'
-        );
-
-      /*        
-      return array(
-        'success' => false,
-        'error' => 'Successful authentication, but session manager is in debug mode - remove the "return array(...);" in includes/sessions.php:' . ( __LINE__ - 2 )
-      );
-      */
-      
-      $sess = $this->register_session(intval($row['user_id']), $username, $password, $level);
-      if($sess)
-      {
-        $this->username = $username;
-        $this->user_id = intval($row['user_id']);
-        $this->theme = $row['theme'];
-        $this->style = $row['style'];
-        
-        if($level > USER_LEVEL_MEMBER)
-          $this->sql('INSERT INTO '.table_prefix.'logs(log_type,action,time_id,date_string,author,edit_summary,page_text) VALUES(\'security\', \'admin_auth_good\', '.time().', \''.enano_date('d M Y h:i a').'\', \''.$db->escape($username).'\', \''.$db->escape($_SERVER['REMOTE_ADDR']).'\', ' . intval($level) . ')');
-        else
-          $this->sql('INSERT INTO '.table_prefix.'logs(log_type,action,time_id,date_string,author,edit_summary) VALUES(\'security\', \'auth_good\', '.time().', \''.enano_date('d M Y h:i a').'\', \''.$db->escape($username).'\', \''.$db->escape($_SERVER['REMOTE_ADDR']).'\')');
-        
-        $code = $plugins->setHook('login_success');
-        foreach ( $code as $cmd )
-        {
-          eval($cmd);
-        }
-        return array(
-          'success' => true
-        );
-      }
-      else
-        return array(
-          'success' => false,
-          'error' => 'backend_fail'
-        );
-    }
-    else
-    {
-      if($level > USER_LEVEL_MEMBER)
-        $this->sql('INSERT INTO '.table_prefix.'logs(log_type,action,time_id,date_string,author,edit_summary,page_text) VALUES(\'security\', \'admin_auth_bad\', '.time().', \''.enano_date('d M Y h:i a').'\', \''.$db->escape($username).'\', \''.$db->escape($_SERVER['REMOTE_ADDR']).'\', ' . intval($level) . ')');
-      else
-        $this->sql('INSERT INTO '.table_prefix.'logs(log_type,action,time_id,date_string,author,edit_summary) VALUES(\'security\', \'auth_bad\', '.time().', \''.enano_date('d M Y h:i a').'\', \''.$db->escape($username).'\', \''.$db->escape($_SERVER['REMOTE_ADDR']).'\')');
-        
-      // Do we also need to increment the lockout countdown?
-      if ( $policy != 'disable' && !defined('IN_ENANO_INSTALL') )
-      {
-        $ipaddr = $db->escape($_SERVER['REMOTE_ADDR']);
-        // increment fail count
-        $this->sql('INSERT INTO '.table_prefix.'lockout(ipaddr, timestamp, action) VALUES(\'' . $ipaddr . '\', ' . time() . ', \'credential\');');
-        $fails++;
-        return array(
-            'success' => false,
-            'error' => ( $fails >= $threshold ) ? 'locked_out' : 'invalid_credentials',
-            'lockout_threshold' => $threshold,
-            'lockout_duration' => ( $duration / 60 ),
-            'lockout_fails' => $fails,
-            'time_rem' => ( $duration / 60 ),
-            'lockout_policy' => $policy
-          );
-      }
-        
-      return array(
-        'success' => false,
-        'error' => 'invalid_credentials'
-      );
-    }
+    // Let the LoginAPI do the rest.
+    return $this->login_without_crypto($username, $password, false, $level, $captcha_hash, $captcha_code, $remember);
   }
   
   /**
@@ -823,9 +628,12 @@
    * @param string $password The password -OR- the MD5 hash of the password if $already_md5ed is true
    * @param bool $already_md5ed This should be set to true if $password is an MD5 hash, and should be false if it's plaintext. Defaults to false.
    * @param int $level The privilege level we're authenticating for, defaults to 0
+   * @param string $captcha_hash Optional. If we're locked out and the lockout policy is captcha, this should be the identifier for the code.
+   * @param string $captcha_code Optional. If we're locked out and the lockout policy is captcha, this should be the code the user entered.
+   * @param bool $remember Optional. If true, remembers the session for X days. Otherwise, assigns a short session. Defaults to false.
    */
   
-  function login_without_crypto($username, $password, $already_md5ed = false, $level = USER_LEVEL_MEMBER, $captcha_hash = false, $captcha_code = false)
+  function login_without_crypto($username, $password, $already_md5ed = false, $level = USER_LEVEL_MEMBER, $captcha_hash = false, $captcha_code = false, $remember = false)
   {
     global $db, $session, $paths, $template, $plugins; // Common objects
     
@@ -979,7 +787,7 @@
           'success' => false,
           'error' => 'too_big_for_britches'
         );
-      $sess = $this->register_session(intval($row['user_id']), $username, $real_pass, $level);
+      $sess = $this->register_session(intval($row['user_id']), $username, $real_pass, $level, $remember);
       if($sess)
       {
         if($level > USER_LEVEL_MEMBER)
@@ -1072,10 +880,11 @@
    * @param string $username
    * @param string $password
    * @param int $level The level of access to grant, defaults to USER_LEVEL_MEMBER
+   * @param bool $remember Whether the session should be long-term (true) or not (false). Defaults to short-term.
    * @return bool
    */
    
-  function register_session($user_id, $username, $password, $level = USER_LEVEL_MEMBER)
+  function register_session($user_id, $username, $password, $level = USER_LEVEL_MEMBER, $remember = false)
   {
     // Random key identifier
     $salt = md5(microtime() . mt_rand());
@@ -1086,10 +895,12 @@
     // Unencrypted session key
     $session_key = "u=$username;p=$passha1;s=$salt";
     
+    // Type of key
+    $key_type = ( $level > USER_LEVEL_MEMBER ) ? SK_ELEV : ( $remember ? SK_LONG : SK_SHORT );
+    
     // Encrypt the key
     $aes = AESCrypt::singleton(AES_BITS, AES_BLOCKSIZE);
     $session_key = $aes->encrypt($session_key, $this->private_key, ENC_HEX);
-    $dec_DEBUG = $aes->decrypt($session_key, $this->private_key, ENC_HEX);
     
     // If we're registering an elevated-privilege key, it needs to be on GET
     if($level > USER_LEVEL_MEMBER)
@@ -1122,7 +933,7 @@
       die('Somehow an SQL injection attempt crawled into our session registrar! (2)');
     
     // All done!
-    $query = $this->sql('INSERT INTO '.table_prefix.'session_keys(session_key, salt, user_id, auth_level, source_ip, time) VALUES(\''.$keyhash.'\', \''.$salt.'\', '.$user_id.', '.$level.', \''.$ip.'\', '.$time.');');
+    $query = $this->sql('INSERT INTO '.table_prefix.'session_keys(session_key, salt, user_id, auth_level, source_ip, time, key_type) VALUES(\''.$keyhash.'\', \''.$salt.'\', '.$user_id.', '.$level.', \''.$ip.'\', '.$time.', ' . $key_type . ');');
     return true;
   }
   
@@ -1220,7 +1031,7 @@
     profiler_log("SessionManager: checking session: " . sha1($key) . ": decrypted session key to $decrypted_key");
     // using a normal call to $db->sql_query to avoid failing on errors here
     $query = $db->sql_query('SELECT u.user_id AS uid,u.username,u.password,u.email,u.real_name,u.user_level,u.theme,u.style,u.signature,' . "\n"
-                             . '    u.reg_time,u.account_active,u.activation_key,u.user_lang,u.user_title,k.source_ip,k.time,k.auth_level,COUNT(p.message_id) AS num_pms,' . "\n"
+                             . '    u.reg_time,u.account_active,u.activation_key,u.user_lang,u.user_title,k.source_ip,k.time,k.auth_level,k.key_type,COUNT(p.message_id) AS num_pms,' . "\n"
                              . '    u.user_timezone, x.* FROM '.table_prefix.'session_keys AS k' . "\n"
                              . '  LEFT JOIN '.table_prefix.'users AS u' . "\n"
                              . '    ON ( u.user_id=k.user_id )' . "\n"
@@ -1234,7 +1045,7 @@
     
     if ( !$query && ( defined('IN_ENANO_INSTALL') or defined('IN_ENANO_UPGRADE') ) )
     {
-      $query = $this->sql('SELECT u.user_id AS uid,u.username,u.password,u.email,u.real_name,u.user_level,u.theme,u.style,u.signature,u.reg_time,u.account_active,u.activation_key,k.source_ip,k.time,k.auth_level,COUNT(p.message_id) AS num_pms, 1440 AS user_timezone FROM '.table_prefix.'session_keys AS k
+      $query = $this->sql('SELECT u.user_id AS uid,u.username,u.password,u.email,u.real_name,u.user_level,u.theme,u.style,u.signature,u.reg_time,u.account_active,u.activation_key,k.source_ip,k.time,k.auth_level,COUNT(p.message_id) AS num_pms, 1440 AS user_timezone, ' . SK_SHORT . ' AS key_type FROM '.table_prefix.'session_keys AS k
                              LEFT JOIN '.table_prefix.'users AS u
                                ON ( u.user_id=k.user_id )
                              LEFT JOIN '.table_prefix.'privmsgs AS p
@@ -1289,18 +1100,47 @@
       return false;
     }
     
-    $time_now = time();
-    $time_key = $row['time'] + 900;
-    if($time_now > $time_key && $row['auth_level'] > USER_LEVEL_MEMBER)
+    // timestamp check
+    switch ( $row['key_type'] )
     {
-      // Session timed out
-      // echo '(debug) $session->validate_session: super session timed out<br />';
-      $this->sw_timed_out = true;
-      return false;
+      case SK_SHORT:
+        $time_now = time();
+        $time_key = $row['time'] + ( 60 * intval(getConfig('session_short', '720')) );
+        if ( $time_now > $time_key )
+        {
+          // Session timed out
+          return false;
+        }
+        break;
+      case SK_LONG:
+        if ( intval(getConfig('session_remember_time', '0')) === 0 )
+        {
+          // sessions last infinitely, timestamp validation is therefore successful
+          break;
+        }
+        $time_now = time();
+        $time_key = $row['time'] + ( 86400 * intval(getConfig('session_remember_time', '30')) );
+        if ( $time_now > $time_key )
+        {
+          // Session timed out
+          return false;
+        }
+        break;
+      case SK_ELEV:
+        $time_now = time();
+        $time_key = $row['time'] + 900;
+        if($time_now > $time_key && $row['auth_level'] > USER_LEVEL_MEMBER)
+        {
+          // Session timed out
+          // echo '(debug) $session->validate_session: super session timed out<br />';
+          $this->sw_timed_out = true;
+          return false;
+        }
+        break;
     }
-    
-    // If this is an elevated-access session key, update the time
-    if( $row['auth_level'] > USER_LEVEL_MEMBER )
+        
+    // If this is an elevated-access or short-term session key, update the time
+    if( $row['key_type'] == SK_ELEV || $row['key_type'] == SK_SHORT )
     {
       $this->sql('UPDATE '.table_prefix.'session_keys SET time='.time().' WHERE session_key=\''.$keyhash.'\';');
     }
@@ -3617,6 +3457,8 @@
         $response['username'] = ( $this->user_logged_in ) ? $this->username : false;
         $response['aes_key'] = $this->rijndael_genkey();
         
+        $response['extended_time'] = intval(getConfig('session_remember_time', '30'));
+        
         // Lockout info
         $response['locked_out'] = $locked_out;
         
@@ -3757,7 +3599,7 @@
         
         // attempt the login
         // function login_without_crypto($username, $password, $already_md5ed = false, $level = USER_LEVEL_MEMBER, $captcha_hash = false, $captcha_code = false)
-        $login_result = $this->login_without_crypto($username, $password, false, intval($req['level']), @$req['captcha_hash'], @$req['captcha_code']);
+        $login_result = $this->login_without_crypto($username, $password, false, intval($req['level']), @$req['captcha_hash'], @$req['captcha_code'], @$req['remember']);
         
         if ( $login_result['success'] )
         {
--- a/install/schemas/upgrade/1.1.4-1.1.5-mysql.sql	Tue Aug 12 00:05:09 2008 -0400
+++ b/install/schemas/upgrade/1.1.4-1.1.5-mysql.sql	Tue Aug 12 00:06:35 2008 -0400
@@ -1,2 +1,2 @@
 ALTER TABLE {{TABLE_PREFIX}}session_keys ADD COLUMN key_type tinyint(1) NOT NULL DEFAULT 0;
-
+UPDATE {{TABLE_PREFIX}}session_keys SET key_type = 2 WHERE auth_level > 2;
--- a/install/schemas/upgrade/1.1.4-1.1.5-postgresql.sql	Tue Aug 12 00:05:09 2008 -0400
+++ b/install/schemas/upgrade/1.1.4-1.1.5-postgresql.sql	Tue Aug 12 00:06:35 2008 -0400
@@ -1,2 +1,3 @@
 ALTER TABLE {{TABLE_PREFIX}}session_keys ADD COLUMN key_type smallint NOT NULL DEFAULT 0;
+UPDATE {{TABLE_PREFIX}}session_keys SET key_type = 2 WHERE auth_level > 2;
 
--- a/language/english/admin.json	Tue Aug 12 00:05:09 2008 -0400
+++ b/language/english/admin.json	Tue Aug 12 00:06:35 2008 -0400
@@ -328,6 +328,14 @@
       field_email_smtp_username: 'Username:',
       field_email_smtp_password: 'Password:',
       
+      // Section: Sessions
+      heading_sessions: 'User sessions',
+      hint_sessions_noelev: '<b>Remember:</b> Settings here only affect normal logins - you can\'t change the length of sessions that give you elevated privileges, such as the re-authentication that occurs when you access the administration panel. <a href="http://docs.enanocms.org/Help:Appendix_B">Read about Enano\'s security model</a>.',
+      field_short_time: 'Length of short sessions in minutes:',
+      field_short_time_hint: 'This is how long a user\'s session will last when they don\'t check the "remember me" checkbox. Short sessions are automatically renewed every time a page is loaded.',
+      field_long_time: 'Length of extended sessions in days:',
+      field_long_time_hint: 'This is how long a user\'s session will last when the "remember me" checkbox is selected during their login. Long sessions can\'t be renewed - they always last a fixed amount of time. Set this to 0 to make extended sessions infinite, e.g. they are only terminated when the user logs out manually (this may present a security risk).',
+      
       // Section: avatars
       heading_avatars: 'Avatars',
       avatars_intro: 'Avatars are small images that users can display on their profiles and in comments.',
@@ -388,7 +396,7 @@
       // Section Defective By Design link
       heading_dbd: 'Defective By Design Anti-DRM button',
       dbd_intro: 'The Enano project is strongly against Digital Restrictions Management.',
-      dbd_explain: 'DRM removes the freedoms that every consumer should have: to freely copy and use digital media items they legally purchased to their own devices. Furthermore, DRM technologies can lock you into a specific brand or product, thus stifling interoperability. Showing your opposition to DRM is as easy as checking the box below to place a link to <a href="http://www.defectivebydesign.org">DefectiveByDesign.org</a> on your sidebar.',
+      dbd_explain: 'DRM infringes on consumer rights by using technical locks to prevent you from using your Fair Use rights granted by copyright law. This means that consumers are harmed when they can\'t copy purchased digital media to their own devices. Furthermore, since most DRM schemes are proprietary and designed to prevent interoperability, you can be locked you into a specific brand or product. You can help spread consumer awareness and show your opposition to DRM through this button. It\'s as easy as checking the box below to place a link to <a href="http://www.defectivebydesign.org">DefectiveByDesign.org</a> on your sidebar.',
       field_stopdrm: 'Help stop DRM by placing a link to DBD on the sidebar!',
       
       // Save button
--- a/language/english/core.json	Tue Aug 12 00:05:09 2008 -0400
+++ b/language/english/core.json	Tue Aug 12 00:06:35 2008 -0400
@@ -730,7 +730,11 @@
       unit_gigabytes_short: 'GB',
       unit_terabytes_short: 'TB',
       unit_pixels: 'pixels',
-      unit_pixels_short: 'px'
+      unit_pixels_short: 'px',
+      unit_day: 'day',
+      unit_days: 'days',
+      unit_week: 'week',
+      unit_weeks: 'weeks'
     }
   }
 };
--- a/language/english/user.json	Tue Aug 12 00:05:09 2008 -0400
+++ b/language/english/user.json	Tue Aug 12 00:06:35 2008 -0400
@@ -30,6 +30,7 @@
       login_body_elev: 'You are requesting that a sensitive operation be performed. To continue, please re-enter your password to confirm your identity.',
       login_field_username: 'Username',
       login_field_password: 'Password',
+      login_field_remember: 'Remember session:',
       login_forgotpass_blurb: 'Forgot your password? <a href="%forgotpass_link%">No problem.</a>',
       login_createaccount_blurb: 'Maybe you need to <a href="%reg_link%">create an account</a>.',
       login_field_captcha: 'Code in image',
@@ -44,6 +45,8 @@
       login_success_body: 'You have successfully logged into the %config.site_name% site as "%username%". Redirecting to %redir_target%...',
       login_success_body_mainpage: 'the main page',
       login_success_short: 'Success.',
+      login_check_remember: 'Keep me logged in on this computer for %session_length% %length_units% unless I log out',
+      login_check_remember_infinite: 'Keep me logged in on this computer until I log out',
       
       login_noact_title: 'Account error',
       login_noact_msg_intro: 'It appears that your user account has not yet been activated.',
@@ -70,6 +73,8 @@
       login_ajax_msg_used_temp_pass: 'You have logged in using a temporary password. Before you can log in, you must finish resetting your password. Do you want to reset your real password now?',
       login_ajax_check_dh: 'Enable strong encryption during logon? <a href="http://docs.enanocms.org/Help:Appendix_B#dh" onclick="window.open(this.href); return false;">Learn more</a>',
       login_ajax_check_dh_ie: 'Use a standards-compliant browser to help protect your password. <a href="http://docs.enanocms.org/Help:Appendix_B#dh" onclick="window.open(this.href); return false;">Learn more</a>',
+      login_ajax_check_remember: 'Keep me logged in on this computer for %session_length% %length_units% unless I log out',
+      login_ajax_check_remember_infinite: 'Keep me logged in on this computer until I log out',
       
       err_login_generic_title: 'There was an error in the login process',
       err_key_not_found: 'Enano couldn\'t look up the encryption key used to encrypt your password. This most often happens if a cache rotation occurred during your login attempt, or if you refreshed the login page.',
--- a/plugins/SpecialAdmin.php	Tue Aug 12 00:05:09 2008 -0400
+++ b/plugins/SpecialAdmin.php	Tue Aug 12 00:06:35 2008 -0400
@@ -341,6 +341,15 @@
     if ( in_array($_POST['lockout_policy'], array('disable', 'captcha', 'lockout')) )
       setConfig('lockout_policy', $_POST['lockout_policy']);
     
+    // Session time
+    foreach ( array('session_short_time', 'session_remember_time') as $k )
+    {
+      if ( strval(intval($_POST[$k])) === $_POST[$k] && intval($_POST[$k]) >= 0 )
+      {
+        setConfig($k, $_POST[$k]);
+      }
+    }
+    
     // Avatar settings
     setConfig('avatar_enable', ( isset($_POST['avatar_enable']) ? '1' : '0' ));
     // for these next three values, set the config value if it's a valid integer; this is
@@ -758,6 +767,36 @@
           <?php echo $lang->get('acpgc_field_email_smtp_password'); ?> <input value="<?php if(getConfig('smtp_password') != false) echo 'XXXXXXXXXXXX'; ?>" name="smtp_pass" type="password" size="30" />
         </td>
       </tr>
+      
+    <!-- Session length -->
+    
+      <tr>
+        <th class="subhead" colspan="2"><?php echo $lang->get('acpgc_heading_sessions'); ?></th>
+      </tr>
+      
+      <tr>
+        <td class="row3" colspan="2"><?php echo $lang->get('acpgc_hint_sessions_noelev'); ?></td>
+      </tr>
+      
+      <tr>
+        <td class="row1">
+          <?php echo $lang->get('acpgc_field_short_time'); ?><br />
+          <small><?php echo $lang->get('acpgc_field_short_time_hint'); ?></small>
+        </td>
+        <td class="row1">
+          <input type="text" name="session_short_time" value="<?php echo getConfig('session_short_time', '720'); ?>" size="4" />
+        </td>
+      </tr>
+      
+      <tr>
+        <td class="row2">
+          <?php echo $lang->get('acpgc_field_long_time'); ?><br />
+          <small><?php echo $lang->get('acpgc_field_long_time_hint'); ?></small>
+        </td>
+        <td class="row2">
+          <input type="text" name="session_remember_time" value="<?php echo getConfig('session_remember_time', '30'); ?>" size="4" />
+        </td>
+      </tr>
         
     <!-- Avatar support -->
     
--- a/plugins/SpecialUserFuncs.php	Tue Aug 12 00:05:09 2008 -0400
+++ b/plugins/SpecialUserFuncs.php	Tue Aug 12 00:06:35 2008 -0400
@@ -349,6 +349,52 @@
          }
          ?>
          <?php
+         if ( $level <= USER_LEVEL_MEMBER )
+         {
+           // "remember me" switch
+           // first order of business is to determine what the checkbox should say
+           $session_time = intval(getConfig('session_remember_time', '30'));
+           if ( $session_time === 0 )
+           {
+             // sessions are infinite
+             $text_remember = $lang->get('user_login_check_remember_infinite');
+           }
+           else
+           {
+             // is the number of days evenly divisible by 7? if so, use weeks
+             if ( $session_time % 7 == 0 )
+             {
+               $session_time = $session_time / 7;
+               $unit = 'week';
+             }
+             else
+             {
+               $unit = 'day';
+             }
+             // if it's not equal to 1, pluralize it
+             if ( $session_time != 1 )
+             {
+               $unit .= 's';
+             }
+             $text_remember = $lang->get('user_login_check_remember', array(
+                 'session_length' => $session_time,
+                 'length_units' => $lang->get("etc_unit_$unit")
+               ));
+           }
+           ?>
+           <tr>
+             <td class="row2">
+               <?php echo $lang->get('user_login_field_remember'); ?>
+             </td>
+             <td class="row1" colspan="2">
+               <label>
+                 <input type="checkbox" name="remember" tabindex="3" />
+                 <?php echo $text_remember; ?>
+               </label>
+             </td>
+           </tr>
+           <?php
+         }
          if ( $level <= USER_LEVEL_MEMBER && ( !isset($_GET['use_crypt']) || ( isset($_GET['use_crypt']) && $_GET['use_crypt']!='0' ) ) )
          {
            echo '<tr>
@@ -386,7 +432,7 @@
          ?>
          
          <tr>
-           <th colspan="3" style="text-align: center" class="subhead"><input type="submit" name="login" value="Log in" tabindex="<?php echo ( $level <= USER_LEVEL_MEMBER ) ? '3' : '2'; ?>" /></th>
+           <th colspan="3" style="text-align: center" class="subhead"><input type="submit" name="login" value="Log in" tabindex="<?php echo ( $level <= USER_LEVEL_MEMBER ) ? '4' : '2'; ?>" /></th>
          </tr>
       </table>
     </div>
@@ -479,7 +525,7 @@
     $captcha_code = ( isset($_POST['captcha_code']) ) ? $_POST['captcha_code'] : false;
     if ( $_POST['use_crypt'] == 'yes' )
     {
-      $result = $session->login_with_crypto($_POST['username'], $_POST['crypt_data'], $_POST['crypt_key'], $_POST['challenge_data'], intval($_POST['auth_level']), $captcha_hash, $captcha_code);
+      $result = $session->login_with_crypto($_POST['username'], $_POST['crypt_data'], $_POST['crypt_key'], $_POST['challenge_data'], intval($_POST['auth_level']), $captcha_hash, $captcha_code, isset($_POST['remember']));
     }
     else if ( $_POST['use_crypt'] == 'yes_dh' )
     {
@@ -551,11 +597,11 @@
       $aes = AESCrypt::singleton(AES_BITS, AES_BLOCKSIZE);
       $password = $aes->decrypt($_POST['crypt_data'], $aes_key, ENC_HEX);
       
-      $result = $session->login_without_crypto($_POST['username'], $password, false, intval($_POST['auth_level']), $captcha_hash, $captcha_code);
+      $result = $session->login_without_crypto($_POST['username'], $password, false, intval($_POST['auth_level']), $captcha_hash, $captcha_code, isset($_POST['remember']));
     }
     else
     {
-      $result = $session->login_without_crypto($_POST['username'], $_POST['pass'], false, intval($_POST['auth_level']), $captcha_hash, $captcha_code);
+      $result = $session->login_without_crypto($_POST['username'], $_POST['pass'], false, intval($_POST['auth_level']), $captcha_hash, $captcha_code, isset($_POST['remember']));
     }
    
     if($result['success'])