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: ?>