| 1: | <?php |
| 2: | /* |
| 3: | * SimpleID |
| 4: | * |
| 5: | * Copyright (C) Kelvin Mo 2014-2025 |
| 6: | * |
| 7: | * This program is free software; you can redistribute it and/or |
| 8: | * modify it under the terms of the GNU General Public |
| 9: | * License as published by the Free Software Foundation; either |
| 10: | * version 2 of the License, or (at your option) any later version. |
| 11: | * |
| 12: | * This program is distributed in the hope that it will be useful, |
| 13: | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 14: | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
| 15: | * General Public License for more details. |
| 16: | * |
| 17: | * You should have received a copy of the GNU General Public |
| 18: | * License along with this program; if not, write to the Free |
| 19: | * Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. |
| 20: | * |
| 21: | */ |
| 22: | |
| 23: | namespace SimpleID\Store; |
| 24: | |
| 25: | use \Base; |
| 26: | use \Prefab; |
| 27: | use SimpleID\Crypt\Random; |
| 28: | use SimpleID\Crypt\SecureString; |
| 29: | use SimpleID\ModuleManager; |
| 30: | use SimpleID\Models\User; |
| 31: | |
| 32: | /** |
| 33: | * Singleton class that manages data storage. |
| 34: | * |
| 35: | * Each item that can be stored in SimpleID is identified using an |
| 36: | * *item type* and an *identifier* unique to that type. An item |
| 37: | * is typically (but is not required to be) represented by a class |
| 38: | * implementing the {@link Storable} interface. |
| 39: | * |
| 40: | * A mechanism used to store or retrieve an item (such as a file |
| 41: | * system, a LDAP directory or a database) is called a *store*. |
| 42: | * A store is identified using the format <var>name</var>:<var>method</var>, |
| 43: | * where <var>name</var> represents the item type or the group of item |
| 44: | * types it is able to store or retrieve and <var>method</var> represents |
| 45: | * the operation (`read` or `write`). `default` is used as a special |
| 46: | * method to denote the store to use when no other stores with `read` or |
| 47: | * `write` operation can be found for that store name. |
| 48: | * |
| 49: | * A *store module* is a subclass of {@link StoreModule} that |
| 50: | * implements one or more stores. The stores that a store module implements |
| 51: | * can be found using the {@link StoreModule::getStores()} method. |
| 52: | * |
| 53: | * The use of stores means that multiple store modules can be enabled, with |
| 54: | * each handling a particular store, with a `default` store acting as a |
| 55: | * fallback for each type. |
| 56: | * |
| 57: | * Currently the following stores a defined. The stores in bold are |
| 58: | * required to be implemented for SimpleID to function. |
| 59: | * |
| 60: | * - **user:read** (read from a directory of users) |
| 61: | * - user:write |
| 62: | * - **client:read** (read client data) |
| 63: | * - **client:write** (write client data) |
| 64: | * - **keyvalue:read** (read key-value data) |
| 65: | * - **keyvalue:write** (write key-value data) |
| 66: | * |
| 67: | * These stores are implemented by {@link DefaultStoreModule}. |
| 68: | * |
| 69: | * Data can be loaded and saved using the methods `loadXX()`, `saveXX()` |
| 70: | * `deleteXX()`, where XX is the item type in camel case. These |
| 71: | * are implemented using the magic method {@link __call()}, which |
| 72: | * calls {@link load()}, {@link save()} and {@link delete()}. |
| 73: | * |
| 74: | * ## Special cases |
| 75: | * |
| 76: | * - **Users.** User data are split across two stores: user:read/write |
| 77: | * and user_cfg:read/write (which defaults to keyvalue:read/write). user_cfg |
| 78: | * is the store used by SimpleID to write its own data on users (e.g. user |
| 79: | * preferences and past activity). Splitting user data across two stores |
| 80: | * enable the use of a read-only directory service (e.g. LDAP) for directory |
| 81: | * information (user:read) while using another storage mechanism for |
| 82: | * preferences (user_cfg:read/write). |
| 83: | * |
| 84: | * - **Settings.** Convenience methods {@link getSetting()}, {@link setSetting()} |
| 85: | * and {@link deleteSetting()} are provided. |
| 86: | * |
| 87: | * @method Storable loadAuth(string $id) |
| 88: | * @method void saveAuth(Storable $item) |
| 89: | * @method void deleteAuth(Storable $item) |
| 90: | * @method void saveClient(Storable $item) |
| 91: | * @method Storable|null findUser(string $criteria, string $value) |
| 92: | */ |
| 93: | class StoreManager extends Prefab { |
| 94: | /** @var array<string, StoreModule> a mapping between the identifier of a store and its store module */ |
| 95: | protected $stores = []; |
| 96: | |
| 97: | /** @var array<string, Storable> */ |
| 98: | private $cache = []; |
| 99: | |
| 100: | /** @var string a space delimited list of stores that must be implemented */ |
| 101: | const REQUIRED_STORES = 'user:read client:read client:write keyvalue:read keyvalue:write'; |
| 102: | |
| 103: | /** |
| 104: | * Adds a store module to the store manager. |
| 105: | * |
| 106: | * This is called by {@link SimpleID\Store\StoreModule::__construct()}, so this |
| 107: | * function should generally not needed to be called |
| 108: | * |
| 109: | * @param StoreModule $module a store module |
| 110: | * @param array<string> $stores an array of stores that the module supports |
| 111: | * @return void |
| 112: | */ |
| 113: | public function addStore($module, $stores) { |
| 114: | foreach ($stores as $store) { |
| 115: | $this->stores[$store] = $module; |
| 116: | } |
| 117: | } |
| 118: | |
| 119: | /** |
| 120: | * Checks whether a store module exists to handle each store. |
| 121: | * |
| 122: | * This function triggers a PHP error if there is a store that |
| 123: | * is not handled by at least one store module. |
| 124: | * |
| 125: | * @return void |
| 126: | */ |
| 127: | public function checkStores() { |
| 128: | foreach (explode(' ', self::REQUIRED_STORES) as $store) { |
| 129: | if ($this->getStore($store) === null) { |
| 130: | trigger_error("No store for $store"); |
| 131: | } |
| 132: | } |
| 133: | } |
| 134: | |
| 135: | /** |
| 136: | * Finds a generic item based on specified criteria. The criteria should identify |
| 137: | * a single item uniquely. |
| 138: | * |
| 139: | * The criteria name is specified as a FatFree path. |
| 140: | * |
| 141: | * @param string $type the item type |
| 142: | * @param string $criteria the criteria name |
| 143: | * @param string $value the criteria value |
| 144: | * @return Storable|null the item or null if no item is found |
| 145: | */ |
| 146: | public function find($type, $criteria, $value) { |
| 147: | $store = $this->getStore($type . ':read'); |
| 148: | $id = $store->find($type, $criteria, $value); |
| 149: | if ($id != null) return $this->load($type, $id); |
| 150: | return null; |
| 151: | } |
| 152: | |
| 153: | /** |
| 154: | * Loads generic item data for a specified item ID. |
| 155: | * |
| 156: | * The item ID must exist. You should check whether the item ID exists with |
| 157: | * the {@link exists()} function |
| 158: | * |
| 159: | * @param string $type the item type |
| 160: | * @param string $id the identifier of the item to load |
| 161: | * @return Storable|null data for the specified item |
| 162: | */ |
| 163: | public function load($type, $id) { |
| 164: | $cache_name = $type . ':' . $id; |
| 165: | |
| 166: | if (!isset($this->cache[$cache_name])) { |
| 167: | $store = $this->getStore($type . ':read'); |
| 168: | $storable = $store->read($type, $id); |
| 169: | if ($storable == null) return null; |
| 170: | |
| 171: | $storable->setStoreID($id); |
| 172: | |
| 173: | $this->cache[$cache_name] = $storable; |
| 174: | } |
| 175: | |
| 176: | return $this->cache[$cache_name]; |
| 177: | } |
| 178: | |
| 179: | /** |
| 180: | * Saves item data. |
| 181: | * |
| 182: | * This data is stored in the store file. |
| 183: | * |
| 184: | * @param string $type the item type |
| 185: | * @param Storable $item the item to save |
| 186: | * @return void |
| 187: | * |
| 188: | * @since 0.7 |
| 189: | */ |
| 190: | public function save($type, $item) { |
| 191: | $this->cache[$type . ':' . $item->getStoreID()] = $item; |
| 192: | |
| 193: | $store = $this->getStore($type . ':write'); |
| 194: | $store->write($type, $item->getStoreID(), $item); |
| 195: | } |
| 196: | |
| 197: | /** |
| 198: | * Deletes item data. |
| 199: | * |
| 200: | * This data is stored in the store file. |
| 201: | * |
| 202: | * @param string $type the item type |
| 203: | * @param Storable $item the item to delete |
| 204: | * @return void |
| 205: | */ |
| 206: | public function delete($type, $item) { |
| 207: | $cache_name = $type . ':' . $item->getStoreID(); |
| 208: | $store = $this->getStore($type . ':write'); |
| 209: | if (isset($this->cache[$cache_name])) unset($this->cache[$cache_name]); |
| 210: | $store->delete($type, $item->getStoreID()); |
| 211: | } |
| 212: | |
| 213: | /** |
| 214: | * Loads a user. |
| 215: | * |
| 216: | * @param string $uid the user ID |
| 217: | * @return \SimpleID\Models\User|null the user or null |
| 218: | */ |
| 219: | public function loadUser($uid) { |
| 220: | /** @var \SimpleID\Models\User $user */ |
| 221: | $user = $this->load('user', $uid); |
| 222: | if ($user == null) return null; |
| 223: | |
| 224: | /** @var \SimpleID\Models\User $user_config */ |
| 225: | $user_config = $this->load('user_cfg', $uid); |
| 226: | if ($user_config != null) { |
| 227: | $user->loadData($user_config); |
| 228: | } |
| 229: | |
| 230: | return $user; |
| 231: | } |
| 232: | |
| 233: | /** |
| 234: | * Saves a user. |
| 235: | * |
| 236: | * @param \SimpleID\Models\User $user the user to save |
| 237: | * @return void |
| 238: | */ |
| 239: | public function saveUser($user) { |
| 240: | if ($this->getStore('user:write', false) != null) { |
| 241: | $this->save('user', $user); |
| 242: | } else { |
| 243: | $this->save('user_cfg', $user); |
| 244: | } |
| 245: | } |
| 246: | |
| 247: | /** |
| 248: | * Loads a client, recasted to a specified class if required. |
| 249: | * |
| 250: | * `$class_name` must be a subclass of {@link SimpleID\Models\Client}. If `$class_name` is |
| 251: | * null, then the original class saved with the client is returned. |
| 252: | * |
| 253: | * @param string $cid the client ID |
| 254: | * @param string $class_name the name of the class in which the data is |
| 255: | * to be cast, nor null |
| 256: | * @return \SimpleID\Models\Client|null the client or null |
| 257: | */ |
| 258: | public function loadClient($cid, $class_name = null) { |
| 259: | /** @var \SimpleID\Models\Client $client */ |
| 260: | $client = $this->load('client', $cid); |
| 261: | if ($client == null) return null; |
| 262: | |
| 263: | if (($class_name == null) || !is_subclass_of($class_name, get_class($client), true)) { |
| 264: | return $client; |
| 265: | } else { |
| 266: | $new_client = new $class_name($client->toArray()); |
| 267: | $new_client->loadFieldsFrom($client); |
| 268: | return $new_client; |
| 269: | } |
| 270: | } |
| 271: | |
| 272: | /** |
| 273: | * Loads an application setting. |
| 274: | * |
| 275: | * @param string $name the name of the setting to return |
| 276: | * @param mixed $default the default value to use if this variable has never been set |
| 277: | * @return mixed the value of the setting |
| 278: | */ |
| 279: | public function getSetting($name, $default = NULL) { |
| 280: | $cache_name = 'setting:' . $name; |
| 281: | |
| 282: | if (!isset($this->cache[$cache_name])) { |
| 283: | $store = $this->getStore('keyvalue:read'); |
| 284: | |
| 285: | if (!$store->exists('setting', $name)) return $default; |
| 286: | $setting = $store->read('setting', $name); |
| 287: | if ($setting === null) return $default; |
| 288: | |
| 289: | $this->cache[$cache_name] = $setting; |
| 290: | } |
| 291: | |
| 292: | return $this->cache[$cache_name]; |
| 293: | } |
| 294: | |
| 295: | /** |
| 296: | * Saves an application setting. |
| 297: | * |
| 298: | * @param string $name the name of the setting to save |
| 299: | * @param mixed $value the value of the setting |
| 300: | * @return void |
| 301: | */ |
| 302: | public function setSetting($name, $value) { |
| 303: | $this->cache['setting:' . $name] = $value; |
| 304: | |
| 305: | $store = $this->getStore('keyvalue:write'); |
| 306: | $store->write('setting', $name, $value); |
| 307: | } |
| 308: | |
| 309: | /** |
| 310: | * Deletes an application setting. |
| 311: | * |
| 312: | * @param string $name the name of the setting to delete |
| 313: | * @return void |
| 314: | */ |
| 315: | public function deleteSetting($name) { |
| 316: | $cache_name = 'setting:' . $name; |
| 317: | $store = $this->getStore('keyvalue:write'); |
| 318: | if (isset($this->cache[$cache_name])) unset($this->cache[$cache_name]); |
| 319: | $store->delete('setting', $name); |
| 320: | } |
| 321: | |
| 322: | /** |
| 323: | * Loads or generates a random string to be used as an encryption |
| 324: | * and/or key. |
| 325: | * |
| 326: | * This function is a wrapper for {@link getSetting()} to load |
| 327: | * a key. If the value is a {@link SecureString}, then it is |
| 328: | * decoded automatically. If the value is not a SecureString, then |
| 329: | * this function will migrate the value to a SecureString. |
| 330: | * |
| 331: | * If the key does not exist, it is automatically generated using |
| 332: | * the specified number of bytes. |
| 333: | * |
| 334: | * @param string $name the name of the setting to return |
| 335: | * @param bool $binary true if the key is to be returned as a binary string |
| 336: | * @param int<1, max> $num_bytes if the key does not exist, the number of random bytes |
| 337: | * required |
| 338: | * @return string the key as a binary or base64url encoded string |
| 339: | */ |
| 340: | public function getKey($name, $binary = false, $num_bytes = 32) { |
| 341: | $setting = $this->getSetting($name); |
| 342: | |
| 343: | if ($setting == null) { |
| 344: | $rand = new Random(); |
| 345: | $encoded_plaintext = trim(strtr(base64_encode($rand->bytes($num_bytes)), '+/', '-_'), '='); |
| 346: | |
| 347: | $this->setSetting($name, SecureString::fromPlaintext($encoded_plaintext)); |
| 348: | } elseif (is_string($setting)) { |
| 349: | // A base64 or base64url encoded string - convert to SecureString |
| 350: | $encoded_plaintext = trim(strtr($setting, '+/', '-_'), '='); |
| 351: | $this->setSetting($name, SecureString::fromPlaintext($encoded_plaintext)); |
| 352: | } elseif ($setting instanceof SecureString) { |
| 353: | $encoded_plaintext = SecureString::getPlaintext($setting); |
| 354: | } else { |
| 355: | throw new \UnexpectedValueException('Unexpected type for setting'); |
| 356: | } |
| 357: | |
| 358: | return ($binary) ? base64_decode(strtr($encoded_plaintext, '-_', '+/')) : $encoded_plaintext; |
| 359: | } |
| 360: | |
| 361: | /** |
| 362: | * Magic method which calls {@link load()}, {@link save()} and {@link delete()}. |
| 363: | * |
| 364: | * @param string $method |
| 365: | * @param array<mixed> $args |
| 366: | * @return mixed |
| 367: | */ |
| 368: | public function __call($method, $args) { |
| 369: | $f3 = Base::instance(); |
| 370: | list($verb, $type) = explode('_', $f3->snakecase($method), 2); |
| 371: | if (method_exists($this, $verb)) { |
| 372: | array_unshift($args, $type); |
| 373: | return call_user_func_array([ $this, $verb ], $args); |
| 374: | } |
| 375: | } |
| 376: | |
| 377: | /** |
| 378: | * Obtains a store module for a specified store. |
| 379: | * |
| 380: | * @param string $store the name of the store |
| 381: | * @param bool $use_defaults if true, also search for default |
| 382: | * stores |
| 383: | * @return StoreModule|null the store module |
| 384: | */ |
| 385: | protected function getStore($store, $use_defaults = true) { |
| 386: | if (isset($this->stores[$store])) return $this->stores[$store]; |
| 387: | if (!$use_defaults) return null; |
| 388: | |
| 389: | list($type, $op) = explode(':', $store); |
| 390: | $store = $type . ':default'; |
| 391: | |
| 392: | if (isset($this->stores[$store])) return $this->stores[$store]; |
| 393: | if ($type != 'keyvalue') return $this->getStore('keyvalue:' . $op); |
| 394: | |
| 395: | return null; |
| 396: | } |
| 397: | } |
| 398: | |
| 399: | ?> |