1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2014-2026
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: */
92: class StoreManager extends Prefab {
93: /** @var array<string, StoreModule> a mapping between the identifier of a store and its store module */
94: protected $stores = [];
95:
96: /** @var array<string, Storable> */
97: private $cache = [];
98:
99: /** @var string a space delimited list of stores that must be implemented */
100: const REQUIRED_STORES = 'user:read client:read client:write keyvalue:read keyvalue:write';
101:
102: /**
103: * Adds a store module to the store manager.
104: *
105: * This is called by {@link SimpleID\Store\StoreModule::__construct()}, so this
106: * function should generally not needed to be called
107: *
108: * @param StoreModule $module a store module
109: * @param array<string> $stores an array of stores that the module supports
110: * @return void
111: */
112: public function addStore($module, $stores) {
113: foreach ($stores as $store) {
114: $this->stores[$store] = $module;
115: }
116: }
117:
118: /**
119: * Checks whether a store module exists to handle each store.
120: *
121: * This function triggers a PHP error if there is a store that
122: * is not handled by at least one store module.
123: *
124: * @return void
125: */
126: public function checkStores() {
127: foreach (explode(' ', self::REQUIRED_STORES) as $store) {
128: if ($this->getStore($store) === null) {
129: trigger_error("No store for $store");
130: }
131: }
132: }
133:
134: /**
135: * Finds a generic item based on specified criteria. The criteria should identify
136: * a single item uniquely.
137: *
138: * The criteria name is specified as a FatFree path.
139: *
140: * @param string $type the item type
141: * @param string $criteria the criteria name
142: * @param string $value the criteria value
143: * @return Storable|null the item or null if no item is found
144: */
145: public function find($type, $criteria, $value) {
146: $store = $this->getStore($type . ':read');
147: $id = $store->find($type, $criteria, $value);
148: if ($id != null) return $this->load($type, $id);
149: return null;
150: }
151:
152: /**
153: * Loads generic item data for a specified item ID.
154: *
155: * The item ID must exist. You should check whether the item ID exists with
156: * the {@link exists()} function
157: *
158: * @param string $type the item type
159: * @param string $id the identifier of the item to load
160: * @return Storable|null data for the specified item
161: */
162: public function load($type, $id) {
163: $cache_name = $type . ':' . $id;
164:
165: if (!isset($this->cache[$cache_name])) {
166: $store = $this->getStore($type . ':read');
167: $storable = $store->read($type, $id);
168: if ($storable == null) return null;
169:
170: $storable->setStoreID($id);
171:
172: $this->cache[$cache_name] = $storable;
173: }
174:
175: return $this->cache[$cache_name];
176: }
177:
178: /**
179: * Saves item data.
180: *
181: * This data is stored in the store file.
182: *
183: * @param string $type the item type
184: * @param Storable $item the item to save
185: * @return void
186: *
187: * @since 0.7
188: */
189: public function save($type, $item) {
190: $this->cache[$type . ':' . $item->getStoreID()] = $item;
191:
192: $store = $this->getStore($type . ':write');
193: $store->write($type, $item->getStoreID(), $item);
194: }
195:
196: /**
197: * Deletes item data.
198: *
199: * This data is stored in the store file.
200: *
201: * @param string $type the item type
202: * @param Storable $item the item to delete
203: * @return void
204: */
205: public function delete($type, $item) {
206: $cache_name = $type . ':' . $item->getStoreID();
207: $store = $this->getStore($type . ':write');
208: if (isset($this->cache[$cache_name])) unset($this->cache[$cache_name]);
209: $store->delete($type, $item->getStoreID());
210: }
211:
212: /**
213: * Loads a user.
214: *
215: * @param string $uid the user ID
216: * @return \SimpleID\Models\User|null the user or null
217: */
218: public function loadUser($uid) {
219: /** @var \SimpleID\Models\User $user */
220: $user = $this->load('user', $uid);
221: if ($user == null) return null;
222:
223: /** @var \SimpleID\Models\User $user_config */
224: $user_config = $this->load('user_cfg', $uid);
225: if ($user_config != null) {
226: $user->loadData($user_config);
227: }
228:
229: return $user;
230: }
231:
232: /**
233: * Saves a user.
234: *
235: * @param \SimpleID\Models\User $user the user to save
236: * @return void
237: */
238: public function saveUser($user) {
239: if ($this->getStore('user:write', false) != null) {
240: $this->save('user', $user);
241: } else {
242: $this->save('user_cfg', $user);
243: }
244: }
245:
246: /**
247: * Finds a user, including searching through the writeable
248: * attributes (such as saved preferences).
249: *
250: * @param string $criteria the criteria name
251: * @param string $value the criteria value
252: * @return Storable|null the user or null if no user is found
253: */
254: public function findUser($criteria, $value) {
255: // This function only searches through the identity files and not
256: // through the user_cfg files (if they exist)
257: $result = $this->find('user', $criteria, $value);
258:
259: if (($result == null) && ($this->getStore('user:write', false) == null)) {
260: $store = $this->getStore('user_cfg:read');
261: $id = $store->find('user_cfg', $criteria, $value);
262: if ($id != null) $result = $this->loadUser($id);
263: }
264:
265: return $result;
266: }
267:
268: /**
269: * Loads a client, recasted to a specified class if required.
270: *
271: * `$class_name` must be a subclass of {@link SimpleID\Models\Client}. If `$class_name` is
272: * null, then the original class saved with the client is returned.
273: *
274: * @param string $cid the client ID
275: * @param string $class_name the name of the class in which the data is
276: * to be cast, nor null
277: * @return \SimpleID\Models\Client|null the client or null
278: */
279: public function loadClient($cid, $class_name = null) {
280: /** @var \SimpleID\Models\Client $client */
281: $client = $this->load('client', $cid);
282: if ($client == null) return null;
283:
284: if (($class_name == null) || !is_subclass_of($class_name, get_class($client), true)) {
285: return $client;
286: } else {
287: $new_client = new $class_name($client->toArray());
288: $new_client->loadFieldsFrom($client);
289: return $new_client;
290: }
291: }
292:
293: /**
294: * Loads an application setting.
295: *
296: * @param string $name the name of the setting to return
297: * @param mixed $default the default value to use if this variable has never been set
298: * @return mixed the value of the setting
299: */
300: public function getSetting($name, $default = NULL) {
301: $cache_name = 'setting:' . $name;
302:
303: if (!isset($this->cache[$cache_name])) {
304: $store = $this->getStore('keyvalue:read');
305:
306: if (!$store->exists('setting', $name)) return $default;
307: $setting = $store->read('setting', $name);
308: if ($setting === null) return $default;
309:
310: $this->cache[$cache_name] = $setting;
311: }
312:
313: return $this->cache[$cache_name];
314: }
315:
316: /**
317: * Saves an application setting.
318: *
319: * @param string $name the name of the setting to save
320: * @param mixed $value the value of the setting
321: * @return void
322: */
323: public function setSetting($name, $value) {
324: $this->cache['setting:' . $name] = $value;
325:
326: $store = $this->getStore('keyvalue:write');
327: $store->write('setting', $name, $value);
328: }
329:
330: /**
331: * Deletes an application setting.
332: *
333: * @param string $name the name of the setting to delete
334: * @return void
335: */
336: public function deleteSetting($name) {
337: $cache_name = 'setting:' . $name;
338: $store = $this->getStore('keyvalue:write');
339: if (isset($this->cache[$cache_name])) unset($this->cache[$cache_name]);
340: $store->delete('setting', $name);
341: }
342:
343: /**
344: * Loads or generates a random string to be used as an encryption
345: * and/or key.
346: *
347: * This function is a wrapper for {@link getSetting()} to load
348: * a key. If the value is a {@link SecureString}, then it is
349: * decoded automatically. If the value is not a SecureString, then
350: * this function will migrate the value to a SecureString.
351: *
352: * If the key does not exist, it is automatically generated using
353: * the specified number of bytes.
354: *
355: * @param string $name the name of the setting to return
356: * @param bool $binary true if the key is to be returned as a binary string
357: * @param int<1, max> $num_bytes if the key does not exist, the number of random bytes
358: * required
359: * @return string the key as a binary or base64url encoded string
360: */
361: public function getKey($name, $binary = false, $num_bytes = 32) {
362: $setting = $this->getSetting($name);
363:
364: if ($setting == null) {
365: $rand = new Random();
366: $encoded_plaintext = trim(strtr(base64_encode($rand->bytes($num_bytes)), '+/', '-_'), '=');
367:
368: $this->setSetting($name, SecureString::fromPlaintext($encoded_plaintext));
369: } elseif (is_string($setting)) {
370: // A base64 or base64url encoded string - convert to SecureString
371: $encoded_plaintext = trim(strtr($setting, '+/', '-_'), '=');
372: $this->setSetting($name, SecureString::fromPlaintext($encoded_plaintext));
373: } elseif ($setting instanceof SecureString) {
374: $encoded_plaintext = SecureString::getPlaintext($setting);
375: } else {
376: throw new \UnexpectedValueException('Unexpected type for setting');
377: }
378:
379: return ($binary) ? base64_decode(strtr($encoded_plaintext, '-_', '+/')) : $encoded_plaintext;
380: }
381:
382: /**
383: * Magic method which calls {@link load()}, {@link save()} and {@link delete()}.
384: *
385: * @param string $method
386: * @param array<mixed> $args
387: * @return mixed
388: */
389: public function __call($method, $args) {
390: $f3 = Base::instance();
391: list($verb, $type) = explode('_', $f3->snakecase($method), 2);
392: if (method_exists($this, $verb)) {
393: array_unshift($args, $type);
394: return call_user_func_array([ $this, $verb ], $args);
395: }
396: }
397:
398: /**
399: * Obtains a store module for a specified store.
400: *
401: * @param string $store the name of the store
402: * @param bool $use_defaults if true, also search for default
403: * stores
404: * @return StoreModule|null the store module
405: */
406: protected function getStore($store, $use_defaults = true) {
407: if (isset($this->stores[$store])) return $this->stores[$store];
408: if (!$use_defaults) return null;
409:
410: list($type, $op) = explode(':', $store);
411: $store = $type . ':default';
412:
413: if (isset($this->stores[$store])) return $this->stores[$store];
414: if ($type != 'keyvalue') return $this->getStore('keyvalue:' . $op);
415:
416: return null;
417: }
418: }
419:
420: ?>
421: