CVE-2015-8033 (published 2020-08-14T19:15Z)

In Textpattern 4.5.7, the password-reset feature does not securely tether a hash to a user account.

Severity: MEDIUM
CVSS v2 base score: 5.0 (AV:N/AC:L/Au:N/C:N/I:P/A:N)
CVSS v3 base score: 5.3, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N)

Weakness information:
cwe_id: CWE-521
cwe_name: Weak Password Requirements
description: The product does not require that users should have strong passwords, which makes it easier for attackers to compromise user accounts.
extended_description: Authentication mechanisms often rely on a memorized secret (also known as a password) to provide an assertion of identity for a user of a system. It is therefore important that this password be of sufficient complexity and impractical for an adversary to guess. The specific requirements around how complex a password needs to be depends on the type of system being protected. Selecting the correct password requirements and enforcing them through implementation are critical to the overall success of the authentication mechanism.
         

Fix information:
repo_url: https://github.com/textpattern/textpattern
commit_details: repo_url: https://github.com/textpattern/textpattern
author: Stef Dawson
author_date: 2015-11-05 21:56:00+00:00
msg: Security improvements part 1. See Issue #565

* No more emailing plaintext passwords out: password changes and reset requests are dealt with immediately on the login/admin panels.
* Stronger cryptographic hashes for confirmation links that don't leak user ID information.
* Hashes are securely tethered to the user account and auto-expire when logging in or generating a new request. Timeout window of 20 minutes by default for answering reset requests.
* Password input boxes are unmaskable. Saves having a separate confirmation box, simplifies code logic and is better UX.
* Dedicated txp_token table introduced for handling token-based storage and retrieval. Better for plugin authors than abusing txp_discuss_nonce.

More on the way including strength meter and improvements to setup passwords...
repository: repo_url: https://github.com/textpattern/textpattern
repo_name: textpattern/textpattern
description: A flexible, elegant, fast and easy-to-use content management system written in PHP.
repo_language: PHP
owner: textpattern
file_changes: filename: txp_admin.php
change_type: ModificationType.MODIFY
diff: @@ -132,7 +132,7 @@ function change_pass()
 {
     global $txp_user;
 
-    extract(psa(array('new_pass', 'mail_password')));
+    extract(psa(array('new_pass')));
 
     if (empty($new_pass)) {
         author_list(array(gTxt('password_required'), E_ERROR));
@@ -143,18 +143,8 @@ function change_pass()
     $rs = change_user_password($txp_user, $new_pass);
 
     if ($rs) {
-        $message = gTxt('password_changed');
-
-        if ($mail_password) {
-            $email = fetch('email', 'txp_users', 'name', $txp_user);
-
-            send_new_password($new_pass, $email, $txp_user);
-
-            $message .= sp.gTxt('and_mailed_to').sp.$email;
-        }
-
-        $message .= '.';
-
+        // Todo: Move full stop to Textpack.
+        $message = gTxt('password_changed') . '.';
         author_list($message);
     }
 }
@@ -241,12 +231,12 @@ function new_pass_form()
         hed(gTxt('change_password'), 2).
         inputLabel(
             'new_pass',
-            fInput('password', 'new_pass', '', '', '', '', INPUT_REGULAR, '', 'new_pass'),
+            fInput('password', 'new_pass', '', 'txp-maskable', '', '', INPUT_REGULAR, '', 'new_pass'),
             'new_password', '', array('class' => 'txp-form-field edit-admin-new-password')
         ).
         graf(
-            checkbox('mail_password', '1', true, '', 'mail_password').
-            n.tag(gTxt('mail_it'), 'label', array('for' => 'mail_password')), array('class' => 'edit-admin-mail-password')).
+            checkbox('unmask', 1, false, 0, 'show_password').
+            n.tag(gTxt('show_password'), 'label', array('for' => 'show_password')), array('class' => 'edit-admin-show-password')).
         graf(fInput('submit', 'change_pass', gTxt('submit'), 'publish')).
         eInput('admin').
         sInput('change_pass'),
@@ -714,20 +704,11 @@ function admin_multi_edit()
         case 'resetpassword':
 
             foreach ($names as $name) {
-                $passwd = generate_password(PASSWORD_LENGTH);
-
-                if (change_user_password($name, $passwd)) {
-                    $email = safe_field("email", 'txp_users', "name = '".doSlash($name)."'");
-
-                    if (send_new_password($passwd, $email, $name)) {
-                        $changed[] = $name;
-                        $msg = 'author_updated';
-                    } else {
-                        return author_list(array(gTxt('could_not_mail').' '.txpspecialchars($name), E_ERROR));
-                    }
-                }
+                send_reset_confirmation_request($name);
+                $changed[] = $name;
             }
 
+            $msg = 'password_reset_confirmation_request_sent';
             break;
     }
 

programming_language: PHP
filename: txp_auth.php
change_type: ModificationType.MODIFY
diff: @@ -71,23 +71,26 @@ function doLoginForm($message)
     global $textarray_script, $event, $step;
 
     include txpath.'/lib/txplib_head.php';
+
     $event = 'login';
 
+    $stay = (cs('txp_login') && !gps('logout') ? 1 : 0);
+    $reset = gps('reset');
+    $confirm = gps('confirm');
+
     if (gps('logout')) {
         $step = 'logout';
-    } elseif (gps('reset')) {
+    } elseif ($reset) {
         $step = 'reset';
+    } elseif ($confirm) {
+        $step = 'confirm';
     }
 
-    pagetop(gTxt('login'), $message);
-
-    $stay = (cs('txp_login') and !gps('logout') ? 1 : 0);
-    $reset = gps('reset');
-
     $name = join(',', array_slice(explode(',', cs('txp_login')), 0, -1));
     $out = array();
 
     if ($reset) {
+        $pageTitle = gTxt('password_reset');
         $out[] = hed(gTxt('password_reset'), 1, array('id' => 'txp-login-heading')).
             n.tag(
                 n.tag(gTxt('name'), 'label', array('for' => 'login_name')).
@@ -98,7 +101,29 @@ function doLoginForm($message)
             graf(
                 href(gTxt('back_to_login'), 'index.php'), array('class' => 'login-return')).
             hInput('p_reset', 1);
+    } elseif ($confirm) {
+        $pageTitle = gTxt('change_password');
+        $out[] = hed(gTxt('change_password'), 1, array('id' => 'txp-change-password-heading')).
+            n.tag(
+                n.tag(gTxt('new_password'), 'label', array(
+                    'class' => 'txp-form-field-label',
+                    'for' => 'change_password',
+                )).
+                fInput('password', 'p_password', '', 'txp-form-field-input txp-maskable', '', '', INPUT_REGULAR, '', 'change_password', false, true),
+                'div', array('class' => 'txp-form-field change-password')).
+            graf(
+                checkbox('unmask', 1, false, 0, 'show_password').n.
+                tag(gTxt('show_password'), 'label', array('for' => 'show_password'))
+                , array('class' => 'show-password')).
+            graf(
+                fInput('submit', '', gTxt('password_confirm_button'), 'publish').n
+            ).
+            graf(
+                href(gTxt('back_to_login'), 'index.php'), array('class' => 'login-return'));
+        $out[] = hInput('hash', gps('confirm'));
+        $out[] = hInput('p_alter', 1);
     } else {
+        $pageTitle = gTxt('login');
         $out[] = hed(gTxt('login_to_textpattern'), 1, array('id' => 'txp-login-heading')).
             n.tag(
                 n.tag(gTxt('name'), 'label', array('for' => 'login_name')).
@@ -124,6 +149,8 @@ function doLoginForm($message)
         }
     }
 
+    pagetop($pageTitle, $message);
+
     echo form(
         join('', $out), '', '', 'post', 'txp-login', '', 'login_form').
 
@@ -136,6 +163,16 @@ function doLoginForm($message)
 /**
  * Validates the sent login form and creates a session.
  *
+ * During the reset request procedure, it is conceivable to verify the
+ * token as soon as it is presented in the URL, but that would require:
+ *  a) very similar code in both p_confirm and p_alter branches (unless refactored)
+ *  b) some way (other than via the message) to signal back to doLoginForm() that
+ *     the token is bogus so the 'change your password' form is not displayed.
+ *     Perhaps raise an exception?
+ *
+ * @todo  Investigate validating confirm token as soon as it's presented in URL (better UX).
+ * @todo  Could this be done via a Validator()?
+ *
  * @return string A localised feedback message
  * @see    doLoginForm()
  */
@@ -143,15 +180,18 @@ function doLoginForm($message)
 function doTxpValidate()
 {
     global $logout, $txp_user;
+
     $p_userid   = ps('p_userid');
     $p_password = ps('p_password');
     $p_reset    = ps('p_reset');
+    $p_alter    = ps('p_alter');
     $stay       = ps('stay');
+    $p_confirm  = gps('confirm');
     $logout     = gps('logout');
     $message    = '';
     $pub_path   = preg_replace('|//$|', '/', rhu.'/');
 
-    if (cs('txp_login') and strpos(cs('txp_login'), ',')) {
+    if (cs('txp_login') && strpos(cs('txp_login'), ',')) {
         $txp_login = explode(',', cs('txp_login'));
         $c_hash = end($txp_login);
         $c_userid = join(',', array_slice($txp_login, 0, -1));
@@ -165,7 +205,11 @@ function doTxpValidate()
         setcookie('txp_login_public', '', time() - 3600, $pub_path);
     }
 
-    if ($c_userid and strlen($c_hash) == 32) { // Cookie exists.
+    if ($c_userid && strlen($c_hash) === 32) {
+        // Cookie exists.
+        // @todo Improve security by using a better nonce/salt mechanism. md5 and uniqid are bad.
+        // @todo Flag cookie-based logins and force confirmation of old password when
+        // changing it from Admin->Users panel.
         $r = safe_row(
             "name, nonce",
             'txp_users',
@@ -193,7 +237,8 @@ function doTxpValidate()
             setcookie('txp_login_public', '', time() - 3600, $pub_path);
             $message = array(gTxt('bad_cookie'), E_ERROR);
         }
-    } elseif ($p_userid and $p_password) { // Incoming login vars.
+    } elseif ($p_userid && $p_password) {
+        // Incoming login vars.
         $name = txp_validate($p_userid, $p_password);
 
         if ($name !== false) {
@@ -218,7 +263,7 @@ function doTxpValidate()
 
             setcookie(
                 'txp_login_public',
-                substr(md5($nonce), - 10).$name,
+                substr(md5($nonce), -10).$name,
                 ($stay ? time() + 3600 * 24 * 30 : 0),
                 $pub_path
             );
@@ -232,25 +277,50 @@ function doTxpValidate()
             txp_status_header('401 Could not log in with that username/password');
             $message = array(gTxt('could_not_log_in'), E_ERROR);
         }
-    } elseif ($p_reset) { // Reset request.
+    } elseif ($p_reset) {
+        // Reset request.
         sleep(3);
 
         include_once txpath.'/lib/txplib_admin.php';
 
         $message = ($p_userid) ? send_reset_confirmation_request($p_userid) : '';
-    } elseif (gps('reset')) {
-        $message = '';
-    } elseif (gps('confirm')) {
+    } elseif ($p_alter) {
+        // Password change confirmation.
         sleep(3);
+        global $sitename;
 
-        $confirm = pack('H*', gps('confirm'));
-        $name    = substr($confirm, 5);
-        $nonce   = safe_field("nonce", 'txp_users', "name = '".doSlash($name)."'");
+        $pass = ps('p_password');
 
-        if ($nonce and $confirm === pack('H*', substr(md5($nonce), 0, 10)).$name) {
-            include_once txpath.'/lib/txplib_admin.php';
-
-            $message = reset_author_pass($name);
+        if (trim($pass) === '') {
+            $message = array(gTxt('password_required'), E_ERROR);
+        } else {
+            $hash = gps('hash');
+            $selector = substr($hash, SALT_LENGTH);
+            $tokenInfo = safe_row("reference_id, token, expires", 'txp_token', "selector = '".doSlash($selector)."' AND type='password_reset'");
+
+            if ($tokenInfo) {
+                if (strtotime($tokenInfo['expires']) <= time()) {
+                    $message = array(gTxt('token_expired'), E_ERROR);
+                } else {
+                    $uid = assert_int($tokenInfo['reference_id']);
+                    $row = safe_row("name, email, nonce, pass AS old_pass", 'txp_users', "user_id = $uid");
+
+                    if ($row['nonce'] && ($hash === bin2hex(pack('H*', substr(hash(HASHING_ALGORITHM, $row['nonce'].$selector.$row['old_pass']), 0, SALT_LENGTH))).$selector)) {
+                        if (change_user_password($row['name'], $pass)) {
+                            $body = gTxt('greeting').' '.$row['name'].','.n.n.gTxt('password_change_confirmation');
+                            txpMail($row['email'], "[$sitename] ".gTxt('password_changed'), $body);
+                            $message = gTxt('password_changed');
+
+                            // Invalidate all reset requests in the wild for this user.
+                            safe_delete("txp_token", "reference_id = $uid AND type = 'password_reset'");
+                        }
+                    } else {
+                        $message = array(gTxt('invalid_token'), E_ERROR);
+                    }
+                }
+            } else {
+                $message = array(gTxt('invalid_token'), E_ERROR);
+            }
         }
     }
 

programming_language: PHP
filename: constants.php
change_type: ModificationType.MODIFY
diff: @@ -402,6 +402,53 @@ if (!defined('PASSWORD_SYMBOLS')) {
     define('PASSWORD_SYMBOLS', '23456789abcdefghijkmnopqrstuvwxyz');
 }
 
+if (!defined('HASHING_ALGORITHM')) {
+    /**
+     * Algorithm to use for hashing passwords/reset requests.
+     *
+     * This constant can be overridden from the config.php.
+     *
+     * @package User
+     * @since   4.6.0
+     * @see     PHP's hash_algos() function
+     * @example
+     * define('HASHING_ALGORITHM', 'whirlpool');
+     */
+
+    define('HASHING_ALGORITHM', 'ripemd256');
+}
+
+if (!defined('SALT_LENGTH')) {
+    /**
+     * Length of salt/selector hashes.
+     *
+     * This constant can be overridden from the config.php.
+     *
+     * @package User
+     * @since   4.6.0
+     * @example
+     * define('SALT_LENGTH', '80');
+     */
+
+    define('SALT_LENGTH', '64');
+}
+
+if (!defined('RESET_EXPIRY_MINUTES')) {
+    /**
+     * Length of time (in minutes) that a password reset request remains valid.
+     *
+     * This constant can be overridden from the config.php.
+     * Values under 60 may fall foul of DST changeover times, but meh.
+     *
+     * @package User
+     * @since   4.6.0
+     * @example
+     * define('RESET_EXPIRY_MINUTES', '90');
+     */
+
+    define('RESET_EXPIRY_MINUTES', '20');
+}
+
 if (!defined('LOGIN_COOKIE_HTTP_ONLY')) {
     /**
      * If TRUE, login cookie is set just for HTTP.

programming_language: PHP
filename: txplib_admin.php
change_type: ModificationType.MODIFY
diff: @@ -33,12 +33,16 @@
  * This function can be only executed when the currently authenticated user
  * trying to send the email was granted 'admin.edit' privileges.
  *
- * @param  string $RealName The real name
- * @param  string $name     The login name
- * @param  string $email    The email address
- * @param  string $password The password
- * @return bool FALSE on error.
- * @see    send_new_password()
+ * Should NEVER be used as sending plaintext passwords is wrong.
+ * Will be removed in future, in lieu of sending reset request tokens.
+ *
+ * @param      string $RealName The real name
+ * @param      string $name     The login name
+ * @param      string $email    The email address
+ * @param      string $password The password
+ * @return     bool FALSE on error.
+ * @deprecated in 4.6.0
+ * @see        send_new_password(), send_reset_confirmation_request
  * @example
  * if (send_password('John Doe', 'login', 'example@example.tld', 'password'))
  * {
@@ -70,12 +74,16 @@ function send_password($RealName, $name, $email, $password)
  * If the $name is FALSE, the password is sent to the currently
  * authenticated user.
  *
- * @param  string $password The new password
- * @param  string $email    The email address
- * @param  string $name     The login name
- * @return bool FALSE on error.
- * @see    send_password()
- * @see    reset_author_pass()
+ * Should NEVER be used as sending plaintext passwords is wrong.
+ * Will be removed in future, in lieu of sending reset request tokens.
+ *
+ * @param      string $password The new password
+ * @param      string $email    The email address
+ * @param      string $name     The login name
+ * @return     bool FALSE on error.
+ * @deprecated in 4.6.0
+ * @see        send_reset_confirmation_request
+ * @see        reset_author_pass()
  * @example
  * $pass = generate_password();
  * if (send_new_password($pass, 'example@example.tld', 'user'))
@@ -105,9 +113,9 @@ function send_new_password($password, $email, $name)
  * Sends a password reset link to a user's email address.
  *
  * This function will return a success message even when the specified user
- * doesn't exist. Though an error message could be thrown when user isn't found,
- * this is done due to security. This prevents the function from leaking
- * existing account names.
+ * doesn't exist. Though an error message could be thrown when a user isn't
+ * found, this is done due to security, which prevents the function from
+ * leaking existing account names.
  *
  * @param  string $name The login name
  * @return string A localized message string
@@ -121,18 +129,41 @@ function send_reset_confirmation_request($name)
 {
     global $sitename;
 
-    $rs = safe_row("email, nonce", 'txp_users', "name = '".doSlash($name)."'");
+    $rs = safe_row("user_id, email, nonce, pass", 'txp_users', "name = '".doSlash($name)."'");
 
     if ($rs) {
         extract($rs);
 
-        $confirm = bin2hex(pack('H*', substr(md5($nonce), 0, 10)).$name);
+        $uid = assert_int($user_id);
+
+        // The selector becomes an indirect reference to the txp_users row,
+        // which does not leak information.
+        $selector = Txp::get('\Textpattern\Password\Random')->generate(12);
+        $expiry = strftime('%Y-%m-%d %H:%M:%S', time() + (60 * RESET_EXPIRY_MINUTES));
+
+        // Use a hash of the nonce, selector and password.
+        // This ensures that confirmation requests expire automatically when:
+        //  a) The person next logs in, or
+        //  b) They successfully change their password (usually as a result of this reset request)
+        // Using the selector in the hash just injects randomness, otherwise two requests
+        // back-to-back would generate the same confirmation code.
+        // Old requests for the same user id are purged every time a new request is made.
+        $token = bin2hex(pack('H*', substr(hash(HASHING_ALGORITHM, $nonce . $selector . $pass), 0, SALT_LENGTH)));
+        $confirm = $token.$selector;
+
+        // Remove any previous reset tokens and insert the new one.
+        safe_delete("txp_token", "reference_id = $uid AND type = 'password_reset'");
+        safe_insert("txp_token",
+                "reference_id = $uid,
+                type = 'password_reset',
+                selector = '".doSlash($selector)."',
+                token = '".doSlash($token)."',
+                expires = '".doSlash($expiry)."'
+            ");
 
         $message = gTxt('greeting').' '.$name.','.
-
-            n.n.gTxt('password_reset_confirmation').': '.
+            n.n.gTxt('password_reset_confirmation').
             n.hu.'textpattern/index.php?confirm='.$confirm;
-
         if (txpMail($email, "[$sitename] ".gTxt('password_reset_confirmation_request'), $message)) {
             return gTxt('password_reset_confirmation_request_sent');
         } else {
@@ -141,6 +172,8 @@ function send_reset_confirmation_request($name)
     } else {
         // Though 'unknown_author' could be thrown, send generic 'request_sent'
         // message instead so that (non-)existence of account names are not leaked.
+        // There's a possibility of a timing attack revealing the existence of
+        // an account, which we could defend against to some degree.
         return gTxt('password_reset_confirmation_request_sent');
     }
 }
@@ -151,10 +184,14 @@ function send_reset_confirmation_request($name)
  * Generates a random password of given length using the symbols set in
  * PASSWORD_SYMBOLS constant.
  *
- * @param  int $length The length of the password
- * @return string Random plain-text password
- * @see    PASSWORD_SYMBOLS
- * @see    PASSWORD_LENGTH
+ * Should NEVER be used as it is not cryptographically secure.
+ * Will be removed in future, in lieu of sending reset request tokens.
+ *
+ * @param      int $length The length of the password
+ * @return     string Random plain-text password
+ * @deprecated in 4.6.0
+ * @see        \Textpattern\Password\Generate
+ * @see        \Textpattern\Password\Random
  * @example
  * echo generate_password(128);
  */
@@ -187,10 +224,14 @@ function generate_password($length = 10)
 /**
  * Resets the given user's password and emails it.
  *
- * The old password replaced with a new random-generated one.
+ * The old password is replaced with a new random-generated one.
+ *
+ * Should NEVER be used as sending plaintext passwords is wrong.
+ * Will be removed in future, in lieu of sending reset request tokens.
  *
  * @param  string $name The login name
  * @return string A localized message string
+ * @deprecated in 4.6.0
  * @see    PASSWORD_LENGTH
  * @see    generate_password()
  * @example

programming_language: PHP
filename: textpattern.js
change_type: ModificationType.MODIFY
diff: @@ -1240,6 +1240,79 @@ jQuery.fn.txpSortable = function (options)
     });
 };
 
+/**
+ * Mask/unmask password input field.
+ *
+ * @since  4.6.0
+ */
+
+textpattern.passwordMask = function()
+{
+    $('body').on('click', '#show_password', function() {
+        var inputBox = $(this).closest('form').find('input.txp-maskable');
+        var newType = (inputBox.attr('type') === 'password') ? 'text' : 'password';
+        textpattern.changeType(inputBox, newType);
+    });
+}
+
+/**
+ * Change the type of an input element.
+ *
+ * @param  {object} elem The <input/> element
+ * @param  {string} type The desired type
+ *
+ * @see    https://gist.github.com/3559343 for original
+ * @since  4.6.0
+ */
+
+textpattern.changeType = function(elem, type)
+{
+    if (elem.prop('type') === type) {
+        // Already the correct type.
+        return elem;
+    }
+
+    try {
+        // May fail if browser prevents it.
+        return elem.prop('type', type);
+    } catch(e) {
+        // Create the element by hand.
+        // Clone it via a div (jQuery has no html() method for an element).
+        var html = $("<div>").append(elem.clone()).html();
+
+        // Match existing attributes of type=text or type="text".
+        var regex = /type=(\")?([^\"\s]+)(\")?/;
+
+        // If no match, add the type attribute to the end; otherwise, replace it.
+        var tmp = $(html.match(regex) == null ?
+            html.replace(">", ' type="' + type + '">') :
+            html.replace(regex, 'type="' + type + '"'));
+
+        // Copy data from old element.
+        tmp.data('type', elem.data('type'));
+        var events = elem.data('events');
+        var cb = function(events) {
+            return function() {
+                // Re-bind all prior events.
+                for(var idx in events) {
+                    var ydx = events[idx];
+
+                    for(var jdx in ydx) {
+                        tmp.bind(idx, ydx[jdx].handler);
+                    }
+                }
+            }
+        }(events);
+
+        elem.replaceWith(tmp);
+
+        // Wait a smidge before firing callback.
+        setTimeout(cb, 10);
+
+        return tmp;
+    }
+}
+
 /**
  * Encodes a string for a use in HTML.
  *
@@ -1447,15 +1520,14 @@ textpattern.Route.add('login', function ()
     }
 
     // Focus on either username or password when empty.
-    var has_name = $('#login_name').val().length;
-    var password_box = $('#login_password').val();
-    var has_password = (password_box) ? password_box.length : 0;
-
-    if (!has_name) {
-        $('#login_name').focus();
-    } else if (!has_password) {
-        $('#login_password').focus();
-    }
+    $('#login_form input').each(function() {
+        if (this.value === '') {
+            this.focus();
+            return false;
+        }
+    });
+
+    textpattern.passwordMask();
 });
 
 // Write panel.
@@ -1537,6 +1609,13 @@ textpattern.Route.add('form', function ()
     });
 });
 
+// Admin panel
+
+textpattern.Route.add('admin', function ()
+{
+    textpattern.passwordMask();
+});
+
 // Preferences panel.
 
 textpattern.Route.add('prefs', function ()

programming_language: JavaScript
filename: _to_4.6.0.php
change_type: ModificationType.MODIFY
diff: @@ -196,3 +196,15 @@ safe_drop_index('txp_section', "name");
 
 // The txp_priv table was created for version 1.0, but never used nor created in later versions.
 safe_drop('txp_priv');
+
+// Add generic token table.
+safe_create('txp_token',"
+id           INT          NOT NULL AUTO_INCREMENT,
+reference_id INT          DEFAULT 0,
+type         VARCHAR(255) DEFAULT '',
+selector     CHAR(12)     DEFAULT '',
+token        VARCHAR(255) DEFAULT '',
+expires      DATETIME     DEFAULT '0000-00-00 00:00:00',
+PRIMARY KEY (id)
+"
+);
\ No newline at end of file

programming_language: PHP