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\Auth;
24:
25: use \Base;
26: use \Cache;
27: use \Prefab;
28: use Psr\Log\LogLevel;
29: use SimpleID\ModuleManager;
30: use SimpleID\Store\StoreManager;
31: use SimpleID\Crypt\Random;
32: use SimpleID\Crypt\OpaqueIdentifier;
33: use SimpleID\Util\Events\BaseDataCollectionEvent;
34: use SimpleID\Util\Forms\FormState;
35:
36: /**
37: * The authentication manager.
38: *
39: * This simpleton class is responsible for managing the user's
40: * authentication session with SimpleID.
41: *
42: * ## Key concepts
43: *
44: * The authentication system involves the following key concepts:
45: *
46: * - **Authentication level.** This is the highest level of user interaction
47: * used to authenticate the user in the current session. The higher
48: * the authentication level, the more user interaction is required.
49: * - **Authentication scheme.** A SimpleID module that implements a way
50: * for a user to authenticate by checking credentials presented against
51: * some data store.
52: * - **Authentication mode.** The type of user interaction required for
53: * authentication.
54: *
55: * ## Process
56: *
57: * The authentication process works as follows:
58: *
59: * 1. The PHP session is initialised
60: * 2. The session variables are checked for authentication information.
61: * If the information does not exist, the user is not logged in.
62: * 3. Invokes each authentication scheme module to see if the user
63: * can be logged in using credentials already stored in the browser
64: * (e.g. cookie, SSL certificate). Otherwise the user is not
65: * logged in.
66: * 4. The user may attempt to log in using the routes presented by
67: * the {@link AuthModule}.
68: *
69: *
70: */
71: class AuthManager extends Prefab {
72: const AUTH_LEVEL_SESSION = 0;
73: /**
74: * Constant denoting a non-interactive authentication level providing
75: * limited access to selected scopes. Examples include OAuth tokens
76: * and app passwords.
77: */
78: const AUTH_LEVEL_TOKEN = 1;
79: /**
80: * Constant denoting a non-interactive authentication level providing
81: * full access. Examples include certificate-based authentication
82: * schemes and "remember me" cookies set after a successful authentication
83: * at a higher level
84: */
85: const AUTH_LEVEL_NON_INTERACTIVE = 2;
86: /**
87: * Constant denoting an interactive authentication level with one
88: * credential successfully provided by the user or an external service.
89: * Examples include password authentication and federated authentication.
90: */
91: const AUTH_LEVEL_CREDENTIALS = 3;
92: /**
93: * Constant denoting an interactive authentication level with one
94: * credential successfully provided by the user in the same browser
95: * session. This is typically required for sensitive ("sudo") operations.
96: */
97: const AUTH_LEVEL_REENTER_CREDENTIALS = 4;
98: /**
99: * Constant denoting an interactive authentication level with at least
100: * one physical factor provided and verified. Examples include
101: * two factor authentication (where one factor is a physical factor)
102: * or passkey-based authentication
103: */
104: const AUTH_LEVEL_VERIFIED = 5;
105:
106: /**
107: * Constant denoting an interactive prompt to enter a user identification.
108: * Example of this include:
109: *
110: * - entering a user name or email
111: * - selecting the network for federated login
112: * - selecting an account from a list of previously saved account
113: */
114: const MODE_IDENTIFY_USER = 0;
115: /**
116: * Constant denoting an interactive prompt to enter a credential.
117: */
118: const MODE_CREDENTIALS = self::AUTH_LEVEL_CREDENTIALS;
119: /**
120: * Constant denoting an interactive prompt to reenter a credential.
121: * This is typically required for sensitive ("sudo") operations.
122: */
123: const MODE_REENTER_CREDENTIALS = self::AUTH_LEVEL_REENTER_CREDENTIALS;
124: /**
125: * Constant denoting an interactive prompt for an additional physical
126: * factor
127: */
128: const MODE_VERIFY = self::AUTH_LEVEL_VERIFIED;
129:
130: /** @var string|null */
131: static private $cookie_prefix = null;
132:
133: /** @var Base */
134: protected $f3;
135:
136: /** @var Cache */
137: protected $cache;
138:
139: /** @var \Psr\Log\LoggerInterface */
140: protected $logger;
141:
142: /** @var ModuleManager */
143: protected $mgr;
144:
145: /**
146: * Authentication information for the current session, usually loaded
147: * from a session variable.
148: *
149: * @var array<string, mixed>
150: */
151: private $auth_info = [];
152:
153: /** @var string|null */
154: private $ua_login_state = null;
155:
156: public function __construct() {
157: $this->f3 = Base::instance();
158: $this->cache = Cache::instance();
159: $this->logger = $this->f3->get('logger');
160: $this->mgr = ModuleManager::instance();
161: }
162:
163: /**
164: * Initialises the PHP session system.
165: *
166: * @return void
167: */
168: public function initSession() {
169: $this->logger->log(LogLevel::DEBUG, 'SimpleID\Auth\AuthManager->initSession');
170:
171: if (session_id() == '') {
172: // session_name() has to be called before session_set_cookie_params()
173: session_name($this->getCookieName('sess'));
174: session_start();
175: $this->f3->sync('SESSION');
176: }
177: }
178:
179: /**
180: * Initialises the user system. Loads data for the currently logged-in user,
181: * if any.
182: *
183: * If there is no logged in user and $allow_non_interactive is set to true, the system
184: * queries the authentication scheme modules to determine whether a user can
185: * be logged in with non-interactive authentication
186: *
187: * @param bool $allow_non_interactive allows non-interactive authentication
188: * @return void
189: */
190: public function initUser($allow_non_interactive = true) {
191: $this->logger->log(LogLevel::DEBUG, 'SimpleID\Auth\AuthManager->initUser');
192:
193: if ($this->f3->exists('SESSION.auth') && ($this->cache->get(rawurlencode($this->f3->get('SESSION.auth.uid')) . '.login') == session_id())) {
194: $this->auth_info = $this->f3->get('SESSION.auth');
195:
196: $store = StoreManager::instance();
197: $user = $store->loadUser($this->auth_info['uid']);
198: $this->f3->set('user', $user);
199: } elseif ($allow_non_interactive) {
200: $event = new NonInteractiveAuthEvent();
201: \Events::instance()->dispatch($event);
202:
203: if ($event->isAuthSuccessful()) {
204: $this->login($event);
205: return;
206: }
207: }
208: }
209:
210: /**
211: * Returns whether a user has logged in
212: *
213: * @return bool true if a user has logged in
214: */
215: public function isLoggedIn() {
216: return (isset($this->auth_info['uid']));
217: }
218:
219: /**
220: * Returns the current logged in user
221: *
222: * @return \SimpleID\Models\User|null the current logged in user
223: */
224: public function getUser() {
225: if ($this->isLoggedIn()) return $this->f3->get('user');
226: return null;
227: }
228:
229: /**
230: * Returns the authentication level achieved for this session.
231: *
232: * @return int the authentication level
233: */
234: public function getAuthLevel() {
235: return (isset($this->auth_info['level'])) ? $this->auth_info['level'] : null;
236: }
237:
238: /**
239: * Returns the time the user was authenticated (including via
240: * automatic authentication).
241: *
242: * @return int the time
243: */
244: public function getAuthTime() {
245: return (isset($this->auth_info['time'])) ? $this->auth_info['time'] : null;
246: }
247:
248: /**
249: * Returns the authentication context class references in relation
250: * to the current authentication session.
251: *
252: * @return string the ACR
253: */
254: public function getACR() {
255: if (isset($this->auth_info['acr'])) {
256: return implode(' ', $this->auth_info['acr']);
257: } else {
258: return $this->f3->get('config.acr');
259: }
260: }
261:
262: /**
263: * Sets the user specified by the parameter as the active user.
264: *
265: * This is done by:
266: *
267: * 1. Associating the user and authentication result with the current
268: * browser session maintained by PHP
269: * 2. Storing the session ID against the user in the `login` cache type
270: *
271: * @param AuthResultInterface $result the authentication result
272: * @param FormState $form_state the state of the login form
273: * @return void
274: */
275: public function login(AuthResultInterface $result, FormState $form_state = null) {
276: if ($form_state == null) $form_state = new FormState();
277:
278: $store = StoreManager::instance();
279: $user = $result->getUser();
280: $level = $result->getAuthLevel();
281: $modules = $result->getAuthModuleNames();
282: $acr = $result->getACR();
283:
284: if (($user == null) && isset($form_state['uid'])) {
285: $user = $store->loadUser($form_state['uid']);
286: }
287:
288: $this->f3->set('user', $user);
289:
290: $this->auth_info['uid'] = $user['uid'];
291: $this->auth_info['level'] = $level;
292: $this->auth_info['modules'] = $modules;
293: $this->auth_info['time'] = time();
294: if (count($acr) > 0) $this->auth_info['acr'] = $acr;
295:
296: if ($level >= self::AUTH_LEVEL_NON_INTERACTIVE) {
297: $this->f3->set('SESSION.auth', $this->auth_info);
298: $this->cache->set(rawurlencode($user['uid']) . '.login', session_id());
299:
300: $this->assignUALoginState(true);
301: }
302: if ($level > self::AUTH_LEVEL_NON_INTERACTIVE)
303: $this->logger->log(LogLevel::INFO, 'Login successful: ' . $user['uid']);
304:
305: $event = new LoginEvent($result, $form_state);
306: \Events::instance()->dispatch($event);
307: }
308:
309: /**
310: * Saves the login event in the user's activity log.
311: *
312: * @see LoginEvent
313: * @return void
314: */
315: public function onLoginEvent(LoginEvent $event) {
316: $store = StoreManager::instance();
317: $user = $event->getUser();
318: $level = $event->getAuthLevel();
319: $form_state = $event->getFormState();
320: $modules = $event->getAuthResult()->getAuthModuleNames();
321:
322: if ($level > self::AUTH_LEVEL_NON_INTERACTIVE) {
323: if (!isset($form_state['auth_skip_activity'])) {
324: $activity = [
325: 'type' => 'browser',
326: 'level' => $level,
327: 'modules' => $modules,
328: 'time' => $event->getTime()->getTimestamp(),
329: ];
330: if ($event->getIP()) $activity['remote'] = $event->getIP();
331: if ($event->getUserAgent()) $activity['ua'] = $event->getUserAgent();
332:
333: $user->addActivity($this->assignUAID(), $activity);
334: $store->saveUser($user);
335: }
336: }
337: }
338:
339: /**
340: * Logs out the user by deleting the relevant session information.
341: *
342: * @return void
343: */
344: public function logout() {
345: $user = $this->getUser();
346:
347: $event = new LogoutEvent($user);
348: \Events::instance()->dispatch($event);
349:
350: $this->cache->clear(rawurlencode($user['uid']) . '.login');
351: $this->f3->clear('user');
352:
353: session_unset();
354: session_destroy();
355: session_write_close();
356: $this->f3->set('COOKIE.' . session_name(), '');
357: session_start();
358:
359: $this->assignUALoginState(true);
360:
361: $this->logger->log(LogLevel::INFO, 'Logout successful: ' . $user['uid']);
362: }
363:
364:
365: /**
366: * Assigns and returns a unique ID for the user agent (UAID).
367: *
368: * A UAID uniquely identifies the user agent (e.g. browser) used to
369: * make the HTTP request. The UAID is stored in a long-dated
370: * cookie. Therefore, the UAID may be useful for security purposes.
371: *
372: * This function will look for a cookie sent by the user agent with
373: * the name returned by {@link getCookieName()} with a suffix
374: * of uaid. If the cookie does not exist, it will generate a
375: * UAID and return it to the user agent with a Set-Cookie
376: * response header.
377: *
378: * @param bool $reset true to reset the UAID regardless of whether
379: * the cookie is present
380: * @return string the UAID
381: */
382: public function assignUAID($reset = false) {
383: $name = 'COOKIE.' . $this->getCookieName('uaid');
384:
385: if (($this->f3->exists($name) === true) && !$reset) {
386: $uaid = $this->f3->get($name);
387: } else {
388: $rand = new Random();
389: $uaid = $rand->id();
390: }
391:
392: $this->f3->set($name, $uaid, SIMPLEID_ETERNAL_TOKEN_EXPIRES_IN);
393:
394: return $uaid;
395: }
396:
397: /**
398: * Assigns and returns a unique login state for the current
399: * authenticated session with user agent (UALS).
400: *
401: * A UALS uniquely identifies the current authenticated session with
402: * the user agent (e.g. browser). It is reset with each successful
403: * login and logout. The cookie associated with a UALS is only
404: * valid for the current session.
405: *
406: * This function will look for a cookie sent by the user agent with
407: * the name returned by {@link getCookieName()} with a suffix
408: * of uals. If the cookie does not exist, it will generate a
409: * UALS and return it to the user agent with a Set-Cookie
410: * response header.
411: *
412: * @param bool $reset true to reset the UALS
413: * @return string the UALS
414: */
415: public function assignUALoginState($reset = false) {
416: $name = $this->getCookieName('uals');
417: if (($this->f3->exists('COOKIE.' . $name) === true) && !$reset) {
418: $this->ua_login_state = $this->f3->get('COOKIE.' . $name);
419: } else {
420: $rand = new Random();
421: $opaque = new OpaqueIdentifier();
422:
423: $this->ua_login_state = $opaque->generate($this->assignUAID() . ':' . $rand->id());
424:
425: // We don't use f3->set->COOKIE, as this automatically sets the cookie to be httponly
426: // We want this to be script readable.
427: setcookie($this->getCookieName('uals'), $this->ua_login_state, 0, $this->f3->get('BASE'), '', true, false);
428: }
429:
430: return $this->ua_login_state;
431: }
432:
433: /**
434: * Returns a relatively unique cookie name based on a specified suffix.
435: *
436: * @param string $suffix the cookie name suffix
437: * @return string the cookie name
438: */
439: public function getCookieName($suffix) {
440: if (self::$cookie_prefix == NULL) {
441: $opaque = new OpaqueIdentifier();
442: self::$cookie_prefix = substr($opaque->generate('cookie'), -9) . '_';
443: }
444: return self::$cookie_prefix . $suffix;
445: }
446:
447: /**
448: * @return string
449: */
450: public function toString() {
451: return print_r($this->auth_info, true);
452: }
453: }
454:
455:
456: ?>
457: