Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.80% covered (warning)
87.80%
36 / 41
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Brute_Force_Protection_Login_Attempt_Token
87.80% covered (warning)
87.80%
36 / 41
60.00% covered (warning)
60.00%
3 / 5
14.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render_field
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
4.25
 consume
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 transient_name
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 outstanding_tokens
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Short-lived authorization for a login attempt that Protect already approved.
4 *
5 * @package automattic/jetpack-waf
6 */
7
8namespace Automattic\Jetpack\Waf\Brute_Force_Protection;
9
10use Automattic\Jetpack\IP\Utils as IP_Utils;
11
12/**
13 * Manages short-lived, single-use login attempt tokens.
14 */
15class Brute_Force_Protection_Login_Attempt_Token {
16    /**
17     * Login form field name.
18     */
19    const FIELD_NAME = 'jetpack_protect_login_attempt';
20
21    /**
22     * Token lifetime in seconds.
23     */
24    const EXPIRATION = 600;
25
26    /**
27     * Outstanding tokens kept per IP, so shared IPs and extra tabs work while stockpiling stays bounded.
28     */
29    const MAX_TOKENS_PER_IP = 5;
30
31    /**
32     * Transient prefix. The full name stays within WordPress's 45-character limit.
33     */
34    const TRANSIENT_PREFIX = 'jpp_attempt_';
35
36    /**
37     * Prefix for the atomic, per-token claim.
38     */
39    const CLAIM_TRANSIENT_PREFIX = 'jpp_claim_';
40
41    /**
42     * Brute Force Protection instance.
43     *
44     * @var Brute_Force_Protection
45     */
46    private $protection;
47
48    /**
49     * Constructor.
50     *
51     * @param Brute_Force_Protection $protection Brute Force Protection instance.
52     */
53    public function __construct( Brute_Force_Protection $protection ) {
54        $this->protection = $protection;
55    }
56
57    /**
58     * Render a token for a login attempt that Protect already approved.
59     */
60    public function render_field() {
61        $transient_name = $this->transient_name();
62        if ( ! $transient_name ) {
63            return;
64        }
65
66        try {
67            $token = bin2hex( random_bytes( 16 ) );
68        } catch ( \Exception $error ) {
69            return; // Fail closed by omitting the token.
70        }
71
72        $tokens                             = $this->outstanding_tokens( $transient_name );
73        $tokens[ hash( 'sha256', $token ) ] = time();
74        $tokens                             = array_slice( $tokens, -self::MAX_TOKENS_PER_IP, null, true );
75
76        if ( ! $this->protection->set_transient( $transient_name, $tokens, self::EXPIRATION ) ) {
77            return;
78        }
79
80        printf(
81            '<input type="hidden" name="%1$s" value="%2$s" />',
82            esc_attr( self::FIELD_NAME ),
83            esc_attr( $token )
84        );
85    }
86
87    /**
88     * Consume a submitted token.
89     *
90     * @return bool Whether a valid token was consumed.
91     */
92    public function consume() {
93        if ( ! isset( $_POST[ self::FIELD_NAME ] ) || ! is_string( $_POST[ self::FIELD_NAME ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The random, single-use token authorizes this request.
94            return false;
95        }
96
97        $transient_name = $this->transient_name();
98        if ( ! $transient_name ) {
99            return false;
100        }
101
102        $token_hash = hash( 'sha256', wp_unslash( $_POST[ self::FIELD_NAME ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only hashed; altered input cannot match a stored hash.
103        if ( ! isset( $this->outstanding_tokens( $transient_name )[ $token_hash ] ) ) {
104            return false;
105        }
106
107        // The claim, not the token list, enforces single use, so concurrent renders never lose tokens.
108        return $this->protection->add_login_attempt_claim(
109            self::CLAIM_TRANSIENT_PREFIX . substr( $token_hash, 0, 32 ),
110            self::EXPIRATION
111        );
112    }
113
114    /**
115     * Get the slot for the current IP's outstanding tokens.
116     *
117     * @return string|false
118     */
119    private function transient_name() {
120        $ip = IP_Utils::get_ip();
121
122        return $ip ? self::TRANSIENT_PREFIX . md5( $ip ) : false;
123    }
124
125    /**
126     * Get the unexpired token hashes for a slot.
127     *
128     * @param string $transient_name Slot name.
129     * @return int[] Issue times keyed by token hash.
130     */
131    private function outstanding_tokens( $transient_name ) {
132        $tokens = $this->protection->get_transient( $transient_name );
133        if ( ! is_array( $tokens ) ) {
134            return array();
135        }
136
137        // Each render refreshes the slot's expiry, so expire tokens individually.
138        $oldest = time() - self::EXPIRATION;
139
140        return array_filter(
141            $tokens,
142            static function ( $issued_at ) use ( $oldest ) {
143                return $issued_at > $oldest;
144            }
145        );
146    }
147}