PluginProbe
DoLogin Security / trunk
DoLogin Security vtrunk
5.0.10 4.8.3 trunk 1.0 1.1 1.1.1 1.2 1.2.1 1.2.2 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 1.6 All 64 releases
dologin / src / pswdless.cls.php

pswdless.cls.php in DoLogin Security trunk, at src/pswdless.cls.php

334 lines 10.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Password less class
4 *
5 * @since 1.4
6 * @package dologin
7 */
8
9 namespace dologin;
10
11 defined( 'WPINC' ) || exit;
12
13 class Pswdless extends Instance {
14
15 const TYPE_GEN = 'gen';
16 const TYPE_LOCK = 'lock';
17 const TYPE_DEL = 'del';
18 const TYPE_TOGGLE_ONETIME = 'toggle_onetime';
19 const TYPE_EXPIRE_7 = 'expire_7';
20
21 private $_tb;
22
23 protected function __construct() {
24 $this->_tb = $this->cls( 'Data' )->tb( 'pswdless' );
25 }
26
27 /**
28 * Init
29 *
30 * @since 1.4
31 */
32 public function init() {
33 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
34 if ( ! empty( $_GET['dologin'] ) ) {
35 add_action( 'init', array( $this, 'try_login' ) );
36 }
37 }
38
39 /**
40 * Login
41 *
42 * @since 1.4
43 */
44 public function try_login() {
45 global $wpdb;
46
47 $username = 'N/A';
48 if ( KLSso::force_enabled() ) {
49 $this->_error_page( 'dologin_kl_sso_required', 403 );
50 }
51
52 // This endpoint bypasses wp-login and must apply the same IP rules and failure limits.
53 if ( $this->cls( 'Auth' )->is_ip_denied() ) {
54 $this->_error_page( 'dologin_ip_denied', 403 );
55 }
56 if ( $this->cls( 'Auth' )->is_rate_limited() ) {
57 $this->_error_page( 'dologin_rate_limited', 429 );
58 }
59
60 // Magic-link endpoint authenticated by the secret token in the URL, not a nonce.
61 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
62 $raw_token = isset( $_GET['dologin'] ) && is_string( $_GET['dologin'] ) ? sanitize_text_field( wp_unslash( $_GET['dologin'] ) ) : '';
63 $info = explode( '.', $raw_token );
64 if ( 2 !== count( $info ) || empty( $info[0] ) || empty( $info[1] ) ) {
65 return $this->_failed_login( $username );
66 }
67
68 $pid = (int) $info[0];
69 if ( $pid <= 0 ) {
70 return $this->_failed_login( $username );
71 }
72
73 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; id is prepared.
74 $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM `$this->_tb` WHERE id = %d", $pid ) );
75 if ( $row ) {
76 $user_info = get_userdata( $row->user_id );
77 if ( $user_info ) {
78 $username = $user_info->user_login;
79 }
80 }
81
82 if ( ! $row || ! $user_info || ! Secret::verify_token( 'passwordless-login', (string) $info[1], (string) $row->hash ) ) {
83 return $this->_failed_login( $username );
84 }
85
86 if ( $row->active != 1 ) {
87 $this->_error_page( 'dologin_link_used', 410 );
88 }
89
90 if ( $row->expired_at < time() ) {
91 $this->_error_page( 'dologin_link_expired', 410 );
92 }
93
94 $confirm_nonce_action = 'dologin_pswdless_confirm_' . hash( 'sha256', $raw_token );
95
96 // Show login confirm page.
97 // phpcs:ignore WordPress.Security.NonceVerification.Missing
98 if ( empty( $_POST['confirmed'] ) ) {
99 require_once DOLOGIN_DIR . 'tpl/pswdless_cfm.tpl.php';
100 exit;
101 }
102
103 // phpcs:ignore WordPress.Security.NonceVerification.Missing
104 $confirm_nonce = empty( $_POST['dologin_confirm_nonce'] ) || ! is_string( $_POST['dologin_confirm_nonce'] ) ? '' : sanitize_text_field( wp_unslash( $_POST['dologin_confirm_nonce'] ) );
105 if ( ! wp_verify_nonce( $confirm_nonce, $confirm_nonce_action ) ) {
106 return $this->_failed_login( $username );
107 }
108
109 // Can login, update record first.
110 $q = "UPDATE `$this->_tb` SET last_used_at = %d, count = count + 1";
111 if ( $row->onetime ) {
112 $q .= ', active = 0 ';
113 }
114 $q .= ' WHERE id = %d AND active = 1';
115 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; values are prepared.
116 $updated = $wpdb->query( $wpdb->prepare( $q, array( time(), $pid ) ) );
117 if ( 1 !== $updated ) {
118 $this->_error_page( 'dologin_link_used', 410 );
119 }
120
121 // Login.
122 wp_set_current_user( $user_info->ID );
123 wp_set_auth_cookie( $user_info->ID, false );
124 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- firing the WordPress core hook on programmatic login.
125 do_action( 'wp_login', $user_info->user_login, $user_info );
126
127 nocache_headers();
128
129 Router::redirect( admin_url() );
130 }
131
132 /**
133 * Note failed login
134 *
135 * @since 1.4
136 */
137 private function _failed_login( $username ) {
138 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- firing the WordPress core hook.
139 do_action( 'wp_login_failed', $username );
140 $this->_error_page( 'dologin_link_invalid', 403 );
141 }
142
143 /**
144 * Complete a rejected public token request with a localized page.
145 */
146 private function _error_page( $tag, $status_code ) {
147 GUI::error_page( $tag, $status_code );
148 exit;
149 }
150
151 /**
152 * Expiration set
153 *
154 * @since 1.4
155 */
156 private function _expire_link() {
157 global $wpdb;
158
159 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
160 $pid = empty( $_GET['dologin_id'] ) ? 0 : (int) $_GET['dologin_id'];
161 if ( $pid <= 0 ) {
162 return;
163 }
164
165 $q = "UPDATE `$this->_tb` SET expired_at = GREATEST( expired_at, %d ) + 86400 * 7 WHERE id = %d";
166 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; values are prepared.
167 $wpdb->query( $wpdb->prepare( $q, time(), $pid ) );
168 }
169
170 /**
171 * Switch one time
172 *
173 * @since 1.4
174 */
175 private function _onetime_link() {
176 global $wpdb;
177
178 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
179 $pid = empty( $_GET['dologin_id'] ) ? 0 : (int) $_GET['dologin_id'];
180 if ( $pid <= 0 ) {
181 return;
182 }
183
184 $q = "UPDATE `$this->_tb` SET onetime = ( onetime + 1 ) % 2 WHERE id = %d";
185 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; id is prepared.
186 $wpdb->query( $wpdb->prepare( $q, $pid ) );
187 }
188
189 /**
190 * Lock
191 *
192 * @since 1.4
193 */
194 private function _lock_link() {
195 global $wpdb;
196
197 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
198 $pid = empty( $_GET['dologin_id'] ) ? 0 : (int) $_GET['dologin_id'];
199 if ( $pid <= 0 ) {
200 return;
201 }
202
203 $q = "UPDATE `$this->_tb` SET active = ( active + 1 ) % 2 WHERE id = %d";
204 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; id is prepared.
205 $wpdb->query( $wpdb->prepare( $q, $pid ) );
206 }
207
208 /**
209 * Delete
210 *
211 * @since 1.4.1
212 */
213 public function del_link( $pid = false ) {
214 global $wpdb;
215
216 if ( ! $pid ) {
217 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
218 if ( empty( $_GET['dologin_id'] ) ) {
219 return;
220 }
221
222 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
223 $pid = (int) $_GET['dologin_id'];
224 }
225
226 $pid = (int) $pid;
227 if ( $pid <= 0 ) {
228 return;
229 }
230
231 $q = "DELETE FROM `$this->_tb` WHERE id = %d";
232 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; id is prepared.
233 $wpdb->query( $wpdb->prepare( $q, $pid ) );
234 }
235
236 /**
237 * Generate link
238 *
239 * @since 1.4
240 * @access public
241 */
242 public function gen_link( $src, $uid, $return_url = false ) {
243 global $wpdb;
244
245 $this->cls( 'Data' )->tb_create( 'pswdless' );
246
247 $uid = (int) $uid;
248 $user_info = $uid > 0 ? get_userdata( $uid ) : false;
249 if ( ! $user_info ) {
250 if ( $return_url ) {
251 return 'Invalid User ID';
252 }
253 Router::redirect( admin_url( 'options-general.php?page=dologin' ) );
254 }
255
256 $token = s::rrand( 32 );
257 $hash = Secret::token_hash( 'passwordless-login', $token );
258 if ( ! $hash ) {
259 if ( $return_url ) {
260 return false;
261 }
262 wp_die( esc_html__( 'Failed to protect the passwordless login token.', 'dologin' ) );
263 }
264
265 $q = "INSERT INTO `$this->_tb` SET user_id = %d, hash = %s, dateline = %d, onetime = 1, active = 1, src = %s, expired_at = %d";
266 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery --$this->_tb is a hardcoded internal table name; values are prepared.
267 $inserted = $wpdb->query( $wpdb->prepare( $q, array( $uid, $hash, time(), $src, time() + 86400 * 7 ) ) );
268 $id = $wpdb->insert_id;
269 if ( 1 !== $inserted || $id <= 0 ) {
270 if ( $return_url ) {
271 return false;
272 }
273 wp_die( esc_html__( 'Failed to save the passwordless login link.', 'dologin' ) );
274 }
275
276 $link = admin_url( '?dologin=' . $id . '.' . $token );
277 if ( $return_url ) {
278 return $link;
279 }
280
281 $this->show_generated_link( $link );
282 }
283
284 /**
285 * Display a newly generated bearer link once without persisting its raw token.
286 */
287 private function show_generated_link( $link ) {
288 $back = admin_url( 'options-general.php?page=dologin#pswdless' );
289 $message = '<p>' . esc_html__( 'Copy this passwordless login link now. For database-leak protection, its secret token is not stored and cannot be shown again.', 'dologin' ) . '</p>'
290 . '<p><code style="display:block;overflow-wrap:anywhere;padding:12px;">' . esc_html( $link ) . '</code></p>'
291 . '<p><a class="button button-primary" href="' . esc_url( $back ) . '">' . esc_html__( 'Continue to Passwordless Links', 'dologin' ) . '</a></p>';
292 wp_die( $message, esc_html__( 'Passwordless Link Created', 'dologin' ), array( 'response' => 200 ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- every dynamic value in the assembled admin-only message is escaped above.
293 }
294
295 /**
296 * Handler
297 *
298 * @since 1.4
299 */
300 public function handler() {
301 $type = Router::verify_type();
302
303 switch ( $type ) {
304 case self::TYPE_GEN:
305 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
306 if ( ! empty( $_GET['uid'] ) ) {
307 $user = wp_get_current_user();
308 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
309 $this->gen_link( $user->display_name, (int) $_GET['uid'] );
310 }
311 break;
312
313 case self::TYPE_LOCK:
314 $this->_lock_link();
315 break;
316
317 case self::TYPE_DEL:
318 $this->del_link();
319 break;
320
321 case self::TYPE_TOGGLE_ONETIME:
322 $this->_onetime_link();
323 break;
324
325 case self::TYPE_EXPIRE_7:
326 $this->_expire_link();
327 break;
328
329 default:
330 break;
331 }
332 }
333 }
334