Skip to content

Commit 4394d06

Browse files
authored
Add DB-backed WP_Session_Tokens implementation (#345)
* Add a new mu-plugin that stores user sessions in a custom database table. * Only load the custom session handler on WordPress.org (Staging + Production) * Mark the cache group as global. * Use the autoloader for including the session manager.
1 parent 299aeec commit 4394d06

File tree

3 files changed

+289
-0
lines changed

3 files changed

+289
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
<?php
2+
namespace WordPressdotorg\MU_Plugins\DB_User_Sessions;
3+
4+
class Tokens extends \WP_Session_Tokens {
5+
const MAX_USER_SESSIONS = 100;
6+
const TABLE = 'wporg_user_sessions';
7+
8+
protected function get_sessions() {
9+
$user_sessions = $this->get_all_user_sessions();
10+
11+
return array_filter( $user_sessions, [ $this, 'is_still_valid' ] );
12+
}
13+
14+
protected function get_session( $verifier ) {
15+
$cache_key = $this->user_id . '__' . $verifier;
16+
$session = wp_cache_get( $cache_key, 'user_sessions' );
17+
if ( is_array( $session ) && $this->is_still_valid( $session ) ) {
18+
return $session;
19+
}
20+
21+
$all_sessions = $this->get_all_user_sessions();
22+
if (
23+
! isset( $all_sessions[ $verifier ] ) ||
24+
! $this->is_still_valid( $all_sessions[ $verifier ] )
25+
) {
26+
return null;
27+
}
28+
29+
wp_cache_set( $cache_key, $all_sessions[ $verifier ], 'user_sessions' );
30+
31+
return $all_sessions[ $verifier ];
32+
}
33+
34+
protected function limit_user_sessions( $verifier = null ) {
35+
$all_user_sessions = $this->get_all_user_sessions();
36+
$sessions = [];
37+
38+
foreach( $all_user_sessions as $session_verifier => $session ) {
39+
if ( $verifier === $session_verifier ) {
40+
continue;
41+
}
42+
43+
$sessions[] = [
44+
'login' => $session['login'],
45+
'verifier' => $session_verifier,
46+
'host' => $session['host'] ?? '',
47+
];
48+
}
49+
50+
usort( $sessions, static function( $session_a, $session_b ) {
51+
return -( $session_a['login'] <=> $session_b['login'] );
52+
} );
53+
54+
$session = $sessions[ self::MAX_USER_SESSIONS - 1 ] ?? null;
55+
if ( empty( $session ) ) {
56+
return;
57+
}
58+
59+
$sessions_to_delete = array_map(
60+
static function( $session ) {
61+
return $session['verifier'];
62+
},
63+
array_slice( $sessions, self::MAX_USER_SESSIONS - 50 )
64+
);
65+
66+
$this->delete_sessions_by_verifiers( $sessions_to_delete );
67+
}
68+
69+
protected function update_session( $verifier, $session = null ) {
70+
global $wpdb;
71+
72+
if ( ! $session ) {
73+
return $this->delete_sessions_by_verifiers( [ $verifier ] );
74+
}
75+
76+
// Delete expired sessions
77+
$sessions_to_delete = array();
78+
$all_user_sessions = $this->get_all_user_sessions();
79+
80+
foreach ( $all_user_sessions as $session_verifier => $session_data ) {
81+
if ( $this->is_still_valid( $session_data ) ) {
82+
continue;
83+
}
84+
if ( $verifier == $session_verifier ) {
85+
continue;
86+
}
87+
88+
$sessions_to_delete[] = $session_verifier;
89+
unset( $all_user_sessions[ $session_verifier ] );
90+
}
91+
92+
if ( ! empty( $sessions_to_delete ) ) {
93+
$this->delete_sessions_by_verifiers( $sessions_to_delete );
94+
}
95+
96+
if ( count( $all_user_sessions ) >= self::MAX_USER_SESSIONS ) {
97+
$this->limit_user_sessions( $verifier );
98+
}
99+
100+
$new_session = $this->convert_session_to_db_format( $verifier, $session );
101+
102+
// Not using the heavier REPLACE because we take advantage of the DB Slaves
103+
if ( isset( $all_user_sessions[ $verifier ] ) ) {
104+
$wpdb->update(
105+
self::TABLE,
106+
$new_session,
107+
[
108+
'user_id' => $this->user_id,
109+
'verifier' => $verifier
110+
],
111+
[ '%d', '%s', '%d', '%d', '%s', '%s' ]
112+
);
113+
} else {
114+
$wpdb->insert( self::TABLE, $new_session, [ '%d', '%s', '%d', '%d', '%s', '%s' ] );
115+
}
116+
117+
$this->clear_user_session_cache( $verifier );
118+
}
119+
120+
protected function destroy_other_sessions( $verifier ) {
121+
global $wpdb;
122+
123+
$sessions_to_delete = [];
124+
$all_user_sessions = $this->get_all_user_sessions();
125+
126+
foreach ( $all_user_sessions as $session_verifier => $session_data ) {
127+
if ( $verifier == $session_verifier ) {
128+
continue;
129+
}
130+
131+
$sessions_to_delete[] = $session_verifier;
132+
}
133+
134+
if ( empty( $sessions_to_delete ) ) {
135+
return;
136+
}
137+
138+
$this->delete_sessions_by_verifiers( $sessions_to_delete );
139+
}
140+
141+
protected function destroy_all_sessions() {
142+
$sessions_to_delete = array_keys( $this->get_all_user_sessions() );
143+
if ( empty( $sessions_to_delete ) ) {
144+
return;
145+
}
146+
147+
$this->delete_sessions_by_verifiers( $sessions_to_delete );
148+
}
149+
150+
public static function drop_sessions() {
151+
return; // Not supported.
152+
}
153+
154+
// Internal functions
155+
156+
protected function get_all_user_sessions() {
157+
global $wpdb;
158+
159+
$cache_key = 'sessions__' . $this->user_id;
160+
$sessions = wp_cache_get( $cache_key, 'user_sessions' );
161+
if ( false !== $sessions ) {
162+
return $sessions;
163+
}
164+
165+
$num_sessions = $wpdb->query( $wpdb->prepare(
166+
'SELECT `verifier`, `expiration`, `ip`, `login`, `session_meta` FROM %i WHERE `user_id` = %d',
167+
self::TABLE,
168+
(int) $this->user_id
169+
) );
170+
171+
$user_sessions = $wpdb->last_result;
172+
if ( false === $num_sessions || ! is_array( $user_sessions ) ) {
173+
return [];
174+
}
175+
176+
$sessions = [];
177+
foreach ( $user_sessions as $user_session ) {
178+
$sessions[ $user_session->verifier ] = $this->convert_session_from_db_format( $user_session );
179+
}
180+
181+
wp_cache_add( $cache_key, $sessions, 'user_sessions' );
182+
183+
return $sessions;
184+
}
185+
186+
protected function convert_session_to_db_format( $verifier, $session ) {
187+
$ip = null;
188+
if ( isset( $session['ip'] ) ) {
189+
$ip = inet_pton( $session['ip'] );
190+
unset( $session['ip'] );
191+
}
192+
193+
if ( ! empty( $_SERVER['HTTP_HOST'] ) && ! isset( $session['host'] ) ) {
194+
$session['host'] = $_SERVER['HTTP_HOST'];
195+
}
196+
197+
$expiration = $session['expiration'];
198+
$login = $session['login'];
199+
unset( $session['expiration'], $session['login'] );
200+
201+
return array(
202+
'user_id' => $this->user_id,
203+
'verifier' => $verifier,
204+
'expiration' => $expiration,
205+
'login' => $login,
206+
'ip' => $ip,
207+
'session_meta' => json_encode( $session, JSON_UNESCAPED_UNICODE )
208+
);
209+
}
210+
211+
protected function convert_session_from_db_format( $session ) {
212+
$new_session = (array) json_decode( $session->session_meta );
213+
214+
foreach ( [ 'expiration', 'login' ] as $column ) {
215+
$new_session[$column] = $session->$column;
216+
}
217+
218+
if ( ! empty( $session->ip ) ) {
219+
$new_session['ip'] = inet_ntop( $session->ip );
220+
}
221+
222+
return $new_session;
223+
}
224+
225+
protected function delete_sessions_by_verifiers( $verifiers ) {
226+
global $wpdb;
227+
228+
if ( empty( $verifiers ) || ! is_array( $verifiers ) ) {
229+
return;
230+
}
231+
232+
$verifier_in_sql = implode( "', '", esc_sql( $verifiers ) );
233+
234+
$wpdb->query( $wpdb->prepare(
235+
"DELETE FROM %i WHERE `user_id` = %d AND `verifier` IN ('$verifier_in_sql')",
236+
self::TABLE,
237+
$this->user_id
238+
) );
239+
240+
foreach ( $verifiers as $verifier ) {
241+
$this->clear_user_session_cache( $verifier );
242+
}
243+
}
244+
245+
function clear_user_session_cache( $verifier = false, $clear_all = false ) {
246+
if ( $verifier ) {
247+
wp_cache_delete( $this->user_id . '__' . $verifier, 'user_sessions' );
248+
}
249+
250+
if ( $clear_all ) {
251+
foreach ( $this->get_all_user_sessions() as $verifier => $session ) {
252+
wp_cache_delete( $this->user_id . '__' . $verifier, 'user_sessions' );
253+
}
254+
}
255+
256+
wp_cache_delete( 'sessions__' . $this->user_id, 'user_sessions' );
257+
}
258+
}

mu-plugins/db-user-sessions/index.php

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
namespace WordPressdotorg\MU_Plugins\DB_User_Sessions;
3+
4+
add_filter( 'session_token_manager', function( $manager ) {
5+
if ( in_array( wp_get_environment_type(), [ 'production', 'staging' ], true ) ) {
6+
$manager = __NAMESPACE__ . '\Tokens';
7+
8+
// The user sesions are global, not per-site.
9+
wp_cache_add_global_groups( 'user_sessions' );
10+
}
11+
12+
return $manager;
13+
} );
14+
15+
/*
16+
Database schema:
17+
CREATE TABLE `wporg_user_sessions` (
18+
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
19+
`user_id` bigint(20) unsigned NOT NULL,
20+
`verifier` char(64) NOT NULL,
21+
`expiration` int(10) unsigned NOT NULL,
22+
`ip` varbinary(16) DEFAULT NULL,
23+
`login` int(10) unsigned NOT NULL,
24+
`session_meta` text DEFAULT NULL,
25+
PRIMARY KEY (`id`),
26+
UNIQUE KEY `user_id__verifier` (`user_id`,`verifier`),
27+
KEY `ip` (`ip`),
28+
KEY `login` (`login`)
29+
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=latin1;
30+
*/

mu-plugins/loader.php

+1
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@
2828
require_once __DIR__ . '/plugin-tweaks/index.php';
2929
require_once __DIR__ . '/rest-api/index.php';
3030
require_once __DIR__ . '/skip-to/skip-to.php';
31+
require_once __DIR__ . '/db-user-sessions/index.php';

0 commit comments

Comments
 (0)