(int) $user_id, 'remember' => (bool) $remember, 'redirect_to' => (string) $redirect_to, 'interim' => (bool) $interim, 'code_hash' => (string) $code_hash, 'attempts' => 0, 'sent_at' => time(), 'reason' => '', 'created_at' => time(), 'restarted' => false, 'expires_at' => time() + self::TTL, ), self::TTL ); return $token; } /** * Read a pending login. * * @param string $token The token. * @param bool $fresh Read past this request's cached copy, for a check that must see what * other requests have written since (the code check, under its mutex). * @return array|null Null when there is none, it expired, it is older than MAX_AGE, or the * token is malformed. * * The record carries its own end time, "expires_at", and is refused past it whatever the * transient's timeout says. The transient's timeout is a copy for the store's own expiry; on * an install with a persistent object cache it is not readable back, so the record is the * only place the time left can be kept across writes. */ public static function load( $token, $fresh = false ) { if ( ! self::valid_token( $token ) ) { return null; } $record = $fresh ? self::read_fresh( self::PREFIX . $token ) : get_transient( self::PREFIX . $token ); if ( ! is_array( $record ) || ! isset( $record['user_id'] ) ) { return null; } // Checked here rather than trusted to the transient's own expiry, which a later write // could stretch. A record with no creation time cannot be aged, so it is refused. if ( ! isset( $record['created_at'] ) || (int) $record['created_at'] + self::MAX_AGE <= time() ) { return null; } // The same rule for the end time: a record without one, as 2.6.1 wrote them, cannot be // timed, so a login half-done across the update starts again from the password. if ( ! isset( $record['expires_at'] ) || (int) $record['expires_at'] <= time() ) { return null; } return $record; } /** * A transient as stored now, not as this request first read it. * * Without a persistent object cache, get_transient() serves options this request has already * read from memory, so the two cached option rows are dropped first. With one, the cache is * the store itself and must not be deleted from; the forced read skips only this request's * in-memory copy. * * @param string $key The transient key. * @return mixed */ private static function read_fresh( $key ) { if ( wp_using_ext_object_cache() ) { return wp_cache_get( $key, 'transient', true ); } wp_cache_delete( '_transient_' . $key, 'options' ); wp_cache_delete( '_transient_timeout_' . $key, 'options' ); return get_transient( $key ); } /** * Count a wrong code. * * @param string $token The token. * @param array|null $record The record as the caller just read it, so a fresh read is the one * counted on; read here when not given. * @return int Attempts so far, or 0 when there is no record. */ public static function record_attempt( $token, $record = null ) { $record = null === $record ? self::load( $token ) : $record; if ( null === $record ) { return 0; } ++$record['attempts']; self::store( $token, $record ); return $record['attempts']; } /** * Swap in a fresh code, for "Send a new code", and restart the resend clock. * * The wrong-code count is kept. The five tries belong to the pending login, not to each code: * if a resend reset them, someone holding the password could ask for a new code every half * minute and get five more guesses each time, so a resend must not hand out a fresh guessing * budget. Running out still means typing the password again. * * @param string $token The token. * @param string $code_hash The new hash, or '' to clear the code before a send. * @return bool False when there is no record. */ public static function replace_code( $token, $code_hash ) { $record = self::load( $token ); if ( null === $record ) { return false; } $record['code_hash'] = (string) $code_hash; $record['sent_at'] = time(); // Whatever stopped the last code is no longer the story; a failed send sets it again. $record['reason'] = ''; self::store( $token, $record ); return true; } /** * Give the pending login a full TTL from now, when its first code goes out. Once only. * * The pending login starts at the password, but no code is sent until the user asks for it on * the code screen, which may be minutes later. The first code's ten minutes run from its * email, so the window starts again then. Later codes keep the time left: if every send * restarted the clock, someone holding the password could keep a pending login alive for as * long as they kept asking. Never past MAX_AGE either way. Everything else in the record is kept. * * @param string $token The token. * @return bool False when there is no record, or the window was restarted already. */ public static function restart_window( $token ) { $record = self::load( $token ); if ( null === $record || ! empty( $record['restarted'] ) ) { return false; } $left = max( 1, min( self::TTL, (int) $record['created_at'] + self::MAX_AGE - time() ) ); $record['restarted'] = true; $record['expires_at'] = time() + $left; set_transient( self::PREFIX . $token, $record, $left ); return true; } /** * Record why no code went out, so the screen can name the real cause. * * @param string $token The token. * @param string $reason "no_email", "send_failed" or "limit". * @return bool False when there is no record. */ public static function set_reason( $token, $reason ) { $record = self::load( $token ); if ( null === $record ) { return false; } $record['reason'] = (string) $reason; self::store( $token, $record ); return true; } /** * Count a code email about to go to this user, or refuse it past the cap. * * The per-pending-login limits (five tries, thirty seconds between resends) do not stop * someone who holds the password from typing it again and again, and every time is another * email in the owner's inbox. This caps the emails per user instead, across all their pending * logins. The window runs from the first send, so asking more often never stretches it. * * A send is counted before it is tried, so a failing mail server cannot be hammered either. * * The count is read, raised and written back. Callers hold Lockout's per-user mutex around * this, so parallel sends are counted one after another, and the count is read past this * request's cached copy, so a send another request counted in the meantime is seen. * * @param int $user_id The user. * @return bool False when the cap is reached and nothing may be sent. */ public static function note_send( $user_id ) { $key = self::SENDS_PREFIX . (int) $user_id; $sent = (int) self::read_fresh( $key ); if ( $sent >= self::MAX_SENDS ) { return false; } self::keep_window( $key, $sent + 1 ); return true; } /** * Code emails this user may still be sent in the current window. * * @param int $user_id The user. * @return int */ public static function sends_left( $user_id ) { return max( 0, self::MAX_SENDS - (int) get_transient( self::SENDS_PREFIX . (int) $user_id ) ); } /** * Forget the code emails sent to this user in the current window, so the cap no longer holds. * * @param int $user_id The user. * @return void */ public static function clear_sends( $user_id ) { delete_transient( self::SENDS_PREFIX . (int) $user_id ); } /** * Throw a pending login away: on success, on the fifth wrong code, on cancel. * * @param string $token The token. * @return void */ public static function discard( $token ) { if ( self::valid_token( $token ) ) { delete_transient( self::PREFIX . $token ); } } /** * Write a record back, keeping the time it has left rather than restarting the clock. * * The remainder comes from the record's own end time, so it is the same on every install: * an object-cache install has no timeout option to read it from, and falling back to a full * TTL there would hand every write a fresh window. * * @param string $token The token. * @param array $record The record. * @return void */ private static function store( $token, array $record ) { $left = isset( $record['expires_at'] ) ? (int) $record['expires_at'] - time() : 0; set_transient( self::PREFIX . $token, $record, max( 1, $left ) ); } /** * Write the send count, keeping the time it has left; a new one gets a full TTL. * * @param string $key The transient key. * @param mixed $value The value. * @return void */ private static function keep_window( $key, $value ) { $timeout = (int) get_option( '_transient_timeout_' . $key ); $left = $timeout > 0 ? $timeout - time() : self::TTL; // Object-cache installs have no timeout option; fall back to a full life there, which // at worst gives one extra window. External caches expire on their own. set_transient( $key, $value, $left > 0 ? $left : 1 ); } /** * Only our own tokens reach the options table as keys. * * @param mixed $token Whatever came in. * @return bool */ private static function valid_token( $token ) { return is_string( $token ) && 1 === preg_match( '/^[0-9a-f]{32}$/', $token ); } }