the browser-facing portion of osu!
at master 8.1 kB view raw
1<?php 2 3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4// See the LICENCE file in the repository root for full licence text. 5 6declare(strict_types=1); 7 8namespace App\Libraries\Session; 9 10use App\Events\UserSessionEvent; 11use App\Interfaces\SessionVerificationInterface; 12use Illuminate\Redis\Connections\PhpRedisConnection; 13use Illuminate\Session\Store as BaseStore; 14use Illuminate\Support\Arr; 15use Jenssegers\Agent\Agent; 16 17class Store extends BaseStore implements SessionVerificationInterface 18{ 19 private const PREFIX = 'sessions:'; 20 21 public static function batchDelete(?int $userId, ?array $ids = null): void 22 { 23 $ids ??= static::ids($userId); 24 25 if (empty($ids)) { 26 return; 27 } 28 29 $currentSession = \Session::instance(); 30 if (in_array($currentSession->getId(), $ids, true)) { 31 $currentSession->flush(); 32 } 33 34 $redis = self::redis(); 35 $redis->del($ids); 36 if ($userId !== null) { 37 $idsForEvent = self::keysForEvent($ids); 38 // Also delete ids that were previously stored with prefix which is 39 // the full redis key just like for event. 40 $redis->srem(self::listKey($userId), ...$ids, ...$idsForEvent); 41 UserSessionEvent::newLogout($userId, $idsForEvent)->broadcast(); 42 } 43 } 44 45 public static function findForVerification(string $id): static 46 { 47 return static::findOrNew($id); 48 } 49 50 public static function findOrNew(?string $id = null): static 51 { 52 if ($id !== null) { 53 $currentSession = \Session::instance(); 54 55 if ($currentSession->getId() === $id) { 56 return $currentSession; 57 } 58 } 59 60 $ret = (new SessionManager(\App::getInstance()))->instance(); 61 if ($id !== null) { 62 $ret->setId($id); 63 } 64 $ret->start(); 65 66 return $ret; 67 } 68 69 public static function ids(?int $userId): array 70 { 71 return $userId === null 72 ? [] 73 : array_map( 74 // The ids were previously stored with prefix. 75 fn ($id) => str_starts_with($id, 'osu-next:') ? substr($id, 9) : $id, 76 self::redis()->smembers(self::listKey($userId)), 77 ); 78 } 79 80 public static function keyForEvent(string $id): string 81 { 82 // TODO: use config's database.redis.session.prefix (also in notification-server) 83 return "osu-next:{$id}"; 84 } 85 86 public static function keysForEvent(array $ids): array 87 { 88 return array_map(static::keyForEvent(...), $ids); 89 } 90 91 public static function sessions(int $userId): array 92 { 93 $ids = static::ids($userId); 94 if (empty($ids)) { 95 return []; 96 } 97 98 $sessions = array_combine( 99 $ids, 100 // Sessions are stored double-serialized in redis (session serialization + cache backend serialization) 101 array_map( 102 fn ($s) => $s === null ? null : unserialize(unserialize($s)), 103 self::redis()->mget($ids), 104 ), 105 ); 106 107 // Current session data in redis may be stale 108 $currentSession = \Session::instance(); 109 if ($currentSession->userId() === $userId) { 110 $sessions[$currentSession->getId()] = $currentSession->attributes; 111 } 112 113 $sessionMeta = []; 114 $agent = new Agent(); 115 $expiredIds = []; 116 foreach ($sessions as $id => $session) { 117 if ($session === null) { 118 $expiredIds[] = $id; 119 continue; 120 } 121 122 if (!isset($session['meta'])) { 123 continue; 124 } 125 126 $meta = $session['meta']; 127 $agent->setUserAgent($meta['agent']); 128 129 $sessionMeta[$id] = [ 130 ...$meta, 131 'mobile' => $agent->isMobile() || $agent->isTablet(), 132 'device' => $agent->device(), 133 'platform' => $agent->platform(), 134 'browser' => $agent->browser(), 135 'verified' => (bool) ($session['verified'] ?? false), 136 ]; 137 } 138 139 // cleanup expired sessions 140 static::batchDelete($userId, $expiredIds); 141 142 // returns sessions sorted from most to least recently active 143 return Arr::sortDesc( 144 $sessionMeta, 145 fn ($value) => $value['last_visit'], 146 ); 147 } 148 149 /** 150 * Get the redis key containing the session list for the given user. 151 */ 152 private static function listKey(int $userId): string 153 { 154 return static::PREFIX.$userId; 155 } 156 157 private static function redis(): PhpRedisConnection 158 { 159 return \LaravelRedis::connection($GLOBALS['cfg']['session']['connection']); 160 } 161 162 public function delete(): void 163 { 164 static::batchDelete($this->userId(), [$this->getId()]); 165 } 166 167 public function getKey(): string 168 { 169 return $this->getId(); 170 } 171 172 public function getKeyForEvent(): string 173 { 174 return self::keyForEvent($this->getId()); 175 } 176 177 /** 178 * Used to obtain the instance from Session facade or SessionManager instance 179 */ 180 public function instance(): static 181 { 182 return $this; 183 } 184 185 public function isValidId($id) 186 { 187 // Overridden to allow prefixed id 188 return is_string($id); 189 } 190 191 public function isVerified(): bool 192 { 193 return $this->attributes['verified'] ?? false; 194 } 195 196 public function markVerified(): void 197 { 198 $this->attributes['verified'] = true; 199 $this->save(); 200 } 201 202 /** 203 * Generate a new session id. 204 * 205 * Overridden to delete session from redis - both entry and list. 206 */ 207 public function migrate($destroy = false) 208 { 209 if ($destroy) { 210 $userId = $this->userId(); 211 if ($userId !== null) { 212 // Keep existing attributes 213 $attributes = $this->attributes; 214 static::batchDelete($userId, [$this->getId()]); 215 $this->attributes = $attributes; 216 } 217 } 218 219 return parent::migrate($destroy); 220 } 221 222 /** 223 * Save the session data to storage. 224 * 225 * Overriden to track user sessions in Redis and shorten lifetime for guest sessions. 226 */ 227 public function save() 228 { 229 $userId = $this->userId(); 230 231 if ($this->handler instanceof CacheBasedSessionHandler) { 232 $this->handler->setMinutes($userId === null ? 120 : $GLOBALS['cfg']['session']['lifetime']); 233 } 234 235 parent::save(); 236 237 // TODO: move this to migrate and validate session id in readFromHandler 238 if ($userId !== null) { 239 self::redis()->sadd(self::listKey($userId), $this->getId()); 240 } 241 } 242 243 public function userId(): ?int 244 { 245 // From `Auth::getName()`. 246 // Hardcoded because Auth depends on this class instance which then 247 // calls this functions and would otherwise cause circular dependency. 248 // Note that osu-notification-server also checks this key. Make sure 249 // to also update it if the value changes. 250 return $this->attributes['login_web_59ba36addc2b2f9401580f014c7f58ea4e30989d'] ?? null; 251 } 252 253 protected function generateSessionId() 254 { 255 $userId = $this->userId(); 256 257 return self::PREFIX 258 .($userId ?? 'guest') 259 .':' 260 .parent::generateSessionId(); 261 } 262 263 /** 264 * Read the session data from the handler. 265 * 266 * @return array 267 */ 268 protected function readFromHandler() 269 { 270 // Overridden to force session ids to be regenerated when trying to load a session that doesn't exist anymore 271 if ($data = $this->handler->read($this->getId())) { 272 $data = @unserialize($this->prepareForUnserialize($data)); 273 274 if ($data !== false && !is_null($data) && is_array($data)) { 275 return $data; 276 } 277 } 278 279 $this->regenerate(true); 280 281 return []; 282 } 283}