PluginProbe
The WP Remote WordPress Plugin / 6.76
The WP Remote WordPress Plugin v6.76
6.76 6.72 6.69 6.65 6.62 6.48 6.47 4.87 4.97 5.05 5.09 5.16 5.22 5.24 5.25 5.38 5.41 5.42 5.45 5.47 5.53 5.56 5.65 5.68 5.72 All 54 releases
wpremote / wp_2fa / time_otp_login.php

time_otp_login.php in The WP Remote WordPress Plugin 6.76, at wp_2fa/time_otp_login.php

163 lines 4.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) exit;
3 if (!class_exists('WPRWP2FATimeOTPLogin')) :
4
5 class WPRWP2FATimeOTPLogin {
6 const SECRET_LENGTH = 32;
7 const MAX_ATTEMPTS = 5;
8 const ATTEMPT_WINDOW = 900;
9 const THROTTLED_MESSAGE = 'Too many incorrect codes. Please try again in %s.';
10
11 public static function authenticate($user) {
12 $code = WPRHelper::getRawParam('POST', 'twofa_code');
13 if (!is_string($code) || $code === '') {
14 wp_send_json_success(array('twofa_enabled' => true));
15 exit;
16 }
17
18 $secret = self::loadSecret($user);
19 if ($secret === null) {
20 WPRWP2FA::sendFailure(WPRWP2FA::CONFIG_MESSAGE);
21 }
22
23 $wait = self::secondsUntilNextAttempt($user->ID);
24 if ($wait > 0) {
25 WPRWP2FA::sendFailure(sprintf(self::THROTTLED_MESSAGE, WPRWP2FA::humanWait($wait)));
26 }
27
28 # Counted before the code is checked. An attempt we cannot count must not
29 # happen, and concurrent attempts must not share one count.
30 if (!self::recordAttempt($user->ID)) {
31 WPRWP2FA::sendFailure(sprintf(self::THROTTLED_MESSAGE, WPRWP2FA::humanWait(self::ATTEMPT_WINDOW)));
32 }
33
34 $slice = null;
35 if (ctype_digit($code)) {
36 $slice = WPRWP2FATimeOTP::matchingSlice($secret, $code);
37 }
38
39 $state = self::loadState($user->ID);
40
41 # RFC 6238: a code from a slice already accepted must not be accepted
42 # again. Without this the same code works for its whole 90 second window.
43 if ($slice === null || $slice <= $state['last_slice']) {
44 return new WP_Error('invalid_2fa_code', esc_html(self::invalidCodeMessage()));
45 }
46
47 # Without the slice recorded the code stays replayable for the rest of its
48 # window, so a login that cannot be recorded is refused rather than left
49 # unaccounted for.
50 if (!self::recordSuccess($user->ID, $state, $slice)) {
51 WPRWP2FA::sendFailure(WPRWP2FA::CONFIG_MESSAGE);
52 }
53
54 return $user;
55 }
56
57 public static function clearState($user_id) {
58 delete_user_meta($user_id, WPRWP2FA::ATTEMPTS_META_KEY);
59
60 return !metadata_exists('user', $user_id, WPRWP2FA::ATTEMPTS_META_KEY);
61 }
62
63 private static function secondsUntilNextAttempt($user_id) {
64 $state = self::loadState($user_id);
65 $now = time();
66
67 if ($now - $state['window_at'] >= self::ATTEMPT_WINDOW) {
68 return 0;
69 }
70
71 if ($state['failures'] < self::MAX_ATTEMPTS) {
72 return 0;
73 }
74
75 return $state['window_at'] + self::ATTEMPT_WINDOW - $now;
76 }
77
78 private static function recordAttempt($user_id) {
79 $now = time();
80 $current = self::loadState($user_id);
81
82 $window_at = $current['window_at'];
83 $failures = $current['failures'];
84 if ($now - $window_at >= self::ATTEMPT_WINDOW) {
85 $window_at = $now;
86 $failures = 0;
87 }
88
89 $next = array(
90 'failures' => $failures + 1,
91 'window_at' => $window_at,
92 'last_slice' => $current['last_slice']
93 );
94
95 return update_user_meta($user_id, WPRWP2FA::ATTEMPTS_META_KEY, $next, $current) !== false;
96 }
97
98 private static function recordSuccess($user_id, $current, $slice) {
99 $next = array('failures' => 0, 'window_at' => time(), 'last_slice' => $slice);
100
101 return update_user_meta($user_id, WPRWP2FA::ATTEMPTS_META_KEY, $next, $current) !== false;
102 }
103
104 private static function loadState($user_id) {
105 $blank = array('failures' => 0, 'window_at' => 0, 'last_slice' => 0);
106 $state = get_user_meta($user_id, WPRWP2FA::ATTEMPTS_META_KEY, true);
107
108 if (!self::validState($state, $blank)) {
109 delete_user_meta($user_id, WPRWP2FA::ATTEMPTS_META_KEY);
110 return $blank;
111 }
112
113 return $state;
114 }
115
116 private static function validState($state, $blank) {
117 if (!is_array($state)) {
118 return false;
119 }
120
121 foreach ($blank as $field => $default) {
122 if (!isset($state[$field]) || !is_int($state[$field]) || $state[$field] < 0) {
123 return false;
124 }
125 }
126
127 return true;
128 }
129
130 private static function loadSecret($user) {
131 $info = WPRWP2FAUtils::getSecretInfo(get_user_meta($user->ID, WPRWP2FA::SECRET_META_KEY, true));
132 if (is_null($info['secret']) || is_null($info['is_encrypted'])) {
133 return null;
134 }
135
136 $secret = $info['secret'];
137 if ($info['is_encrypted'] === true) {
138 # An encrypted secret with no key to open it is a misconfiguration, not a usable secret.
139 if (!defined('SECURE_AUTH_KEY')) {
140 return null;
141 }
142
143 $decrypted = WPRHelper::opensslDecrypt($secret, WPRWP2FA::$cipher_algo, SECURE_AUTH_KEY);
144 if ($decrypted[0] === false) {
145 return null;
146 }
147
148 $secret = $decrypted[1];
149 }
150
151 if (!is_string($secret) || strlen($secret) !== self::SECRET_LENGTH) {
152 return null;
153 }
154
155 return $secret;
156 }
157
158 private static function invalidCodeMessage() {
159 return WPRWP2FA::whitelabelMessage('2fa_error_message', WPRWP2FA::INVALID_CODE_MESSAGE);
160 }
161 }
162 endif;
163