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\Auth;
24:
25: use Psr\Log\LogLevel;
26: use SimpleID\Module;
27: use SimpleID\ModuleManager;
28: use SimpleID\Crypt\SecurityToken;
29: use SimpleID\Util\Events\GenericStoppableEvent;
30: use SimpleID\Util\Forms\FormState;
31: use SimpleID\Util\Forms\FormBuildEvent;
32: use SimpleID\Util\Forms\FormSubmitEvent;
33: use SimpleID\Util\UI\Template;
34:
35: /**
36: * The module used to authenticate users.
37: *
38: * This module delegates the actual authentication function to
39: * other modules, using various hooks. Details of the hooks can be
40: * found in the API documention found in the See link
41: *
42: * @see SimpleID\API\AuthHooks
43: */
44: class AuthModule extends Module {
45: /** @var AuthManager */
46: private $auth;
47:
48: static function init($f3) {
49: $f3->route('POST @auth_identify: /auth/identify [ajax]', 'SimpleID\Auth\AuthModule->identifyUser');
50: $f3->route('GET|POST /auth/login', 'SimpleID\Auth\AuthModule->login');
51: $f3->route('GET|POST @auth_login: /auth/login/*', 'SimpleID\Auth\AuthModule->login');
52: $f3->route('GET /auth/logout', 'SimpleID\Auth\AuthModule->logout');
53: $f3->route('GET @auth_logout: /auth/logout/*', 'SimpleID\Auth\AuthModule->logout');
54: }
55:
56: public function __construct() {
57: parent::__construct();
58: $this->auth = AuthManager::instance();
59: }
60:
61: /**
62: * FatFree Framework event handler.
63: *
64: * This module does not use the default event handler provided by {@link SimpleID\Module},
65: * as it needs to disable the automatic authentication.
66: *
67: */
68: public function beforeroute() {
69: $this->auth->initSession();
70: $this->auth->initUser(false);
71: }
72:
73: /**
74: * API endpoint to determine the authentication scheme for a specified user, and whether
75: * the 'password' region of the login form should be displayed.
76: *
77: * @return void
78: */
79: public function identifyUser() {
80: $this->checkHttps('error', true);
81:
82: $dispatcher = \Events::instance();
83:
84: header('Content-Type: application/json');
85:
86: $token = new SecurityToken();
87: if (($this->f3->exists('POST.tk') === false) || !$token->verify($this->f3->get('POST.tk'), 'login')) {
88: $this->f3->status(401);
89: print json_encode([
90: 'error' => 'unauthorized',
91: 'error_description' => $this->f3->get('intl.common.unauthorized')
92: ]);
93: return;
94: }
95:
96: if (($this->f3->exists('POST.fs') === false)) {
97: $this->f3->status(400);
98: print json_encode([
99: 'error' => 'invalid_request',
100: 'error_description' => $this->f3->get('intl.core.auth.state_error')
101: ]);
102: return;
103: }
104:
105: $form_state = FormState::decode($token->getPayload($this->f3->get('POST.fs')));
106:
107: // We always reset mode to AuthManager::MODE_IDENTIFY_USER
108: $form_state['mode'] = AuthManager::MODE_IDENTIFY_USER;
109:
110: $submit_event = new LoginFormSubmitEvent($form_state, 'login_form_submit');
111: $dispatcher->dispatch($submit_event);
112:
113: // We set request_password as the default action, so that an attacker
114: // cannot guess what authentication is being used.
115: $action = 'request_password';
116: $form_state['mode'] = AuthManager::MODE_CREDENTIALS;
117:
118: if ($submit_event->isValid()) {
119: $auth_modules = $submit_event->getAuthModuleNames();
120: // If there are multiple authentication options, or if the single option
121: // is not PasswordAuthSchemeModule, then submit the form with op
122: // show_credentials_form to show the credentials form.
123: //
124: // Otherwise, expand the password region.
125: if ((count($auth_modules) > 1)
126: || ((count($auth_modules) == 1) && ($auth_modules[0] != PasswordAuthSchemeModule::class))
127: ) {
128: $action = 'show_credentials_form';
129: }
130: }
131:
132: print json_encode([
133: 'action' => $action,
134: 'fs' => $token->generate($form_state->encode()),
135: 'tk' => $token->generate('login', SecurityToken::OPTION_NONCE),
136: 'mode' => $form_state['mode'],
137: ]);
138: }
139:
140: /**
141: * Attempts to log in a user, using the credentials specified in the
142: * HTTP request.
143: *
144: * @param \Base $f3
145: * @param array<string, mixed> $params
146: * @return void
147: */
148: public function login($f3, $params) {
149: $dispatcher = \Events::instance();
150:
151: $params['destination'] = (isset($params['*'])) ? $params['*'] : '';
152: $this->f3->set('PARAMS.destination', $params['destination']);
153:
154: $token = new SecurityToken();
155:
156: // Require HTTPS or return an error
157: $this->checkHttps('error', true);
158:
159: if (($this->f3->exists('POST.fs') === false)) {
160: $form_state = new FormState([ 'mode' => AuthManager::MODE_IDENTIFY_USER ]);
161: if (in_array($this->f3->get('GET.mode'), [ AuthManager::MODE_CREDENTIALS, AuthManager::MODE_VERIFY, AuthManager::MODE_REENTER_CREDENTIALS ])) {
162: $form_state['mode'] = $this->f3->get('GET.mode');
163: }
164: $this->loginForm($params, $form_state);
165: return;
166: }
167:
168: $form_state = FormState::decode($token->getPayload($this->f3->get('POST.fs')));
169: if (count($form_state) == 0) $form_state['mode'] = AuthManager::MODE_IDENTIFY_USER;
170: $mode = $form_state['mode'];
171: if (!in_array($mode, [ AuthManager::MODE_IDENTIFY_USER, AuthManager::MODE_CREDENTIALS, AuthManager::MODE_REENTER_CREDENTIALS, AuthManager::MODE_VERIFY ])) {
172: $this->f3->set('message', $this->f3->get('intl.core.auth.state_error'));
173: $this->loginForm($params, $form_state);
174: return;
175: }
176:
177: if ($this->f3->exists('POST.tk') === false) {
178: if ($params['destination']) {
179: // User came from a log in form.
180: $this->f3->set('message', $this->f3->get('intl.core.auth.missing_tk'));
181: }
182: $this->loginForm($params, $form_state);
183: return;
184: }
185:
186: if (!$token->verify($this->f3->get('POST.tk'), 'login')) {
187: $this->logger->log(LogLevel::WARNING, 'Login attempt: Security token ' . $this->f3->get('POST.tk') . ' invalid.');
188: $this->f3->set('message', $this->f3->get('intl.core.auth.state_error'));
189: $this->loginForm($params, $form_state);
190: return;
191: }
192:
193: if ($this->f3->exists('POST.op')) {
194: switch ($this->f3->get('POST.op')) {
195: case 'cancel':
196: $cancel_event = new FormSubmitEvent($form_state, 'login_form_cancel');
197:
198: $dispatcher->dispatch($cancel_event);
199:
200: // Listeners should call stopPropagation if it has processed successfully
201: if (!$cancel_event->isPropagationStopped()) {
202: $this->fatalError($this->f3->get('intl.core.auth.cancelled'), 400);
203: }
204: return;
205: case 'show_credentials_form':
206: // uid, if required, would still be in POST.uid
207: $form_state['mode'] = AuthManager::MODE_CREDENTIALS;
208: $this->loginForm($params, $form_state);
209: return;
210: case 'show_identity_form':
211: $form_state['mode'] = AuthManager::MODE_IDENTIFY_USER;
212: $this->loginForm($params, $form_state);
213: return;
214: }
215: }
216:
217: // If the user is already logged in, return
218: if (in_array($mode, [ AuthManager::MODE_IDENTIFY_USER, AuthManager::MODE_CREDENTIALS ])
219: && $this->auth->isLoggedIn())
220: $this->f3->reroute('/');
221:
222: $validate_event = new FormSubmitEvent($form_state, 'login_form_validate');
223: $dispatcher->dispatch($validate_event);
224: if (!$validate_event->isValid()) {
225: $this->f3->set('message', $validate_event->getMessages());
226: $this->loginForm($params, $form_state);
227: return;
228: }
229:
230: $submit_event = new LoginFormSubmitEvent($form_state, 'login_form_submit');
231: $dispatcher->dispatch($submit_event);
232: if (!$submit_event->isValid()) {
233: $this->f3->set('message', $submit_event->getMessages());
234: $this->loginForm($params, $form_state);
235: return;
236: }
237:
238: if ($submit_event->isAuthSuccessful()) {
239: // $submit_event->getUser() can be null when mode is MODE_VERIFY or MODE_REENTER_CREDENTIALS
240: // In these cases $form_state['uid'] would already be populated
241: $test_user = $submit_event->getUser();
242: if ($test_user != null) $form_state['uid'] = $test_user['uid'];
243:
244: $form_state['auth_level'] = $submit_event->getAuthLevel();
245: $form_state['modules'] = $submit_event->getAuthModuleNames();
246: } else {
247: $this->f3->set('message', $submit_event->getMessages());
248: $this->loginForm($params, $form_state);
249: return;
250: }
251:
252: if (!isset($form_state['uid'])) {
253: // No user
254: $this->loginForm($params, $form_state);
255: return;
256: }
257:
258: if ($mode == AuthManager::MODE_CREDENTIALS) {
259: $form_state['mode'] = AuthManager::MODE_VERIFY;
260: $event = new LoginFormBuildEvent($form_state, 'login_form_build');
261:
262: $dispatcher->dispatch($event);
263: $blocks = $event->getBlocksGroupedByRegion();
264: if (isset($blocks[LoginFormBuildEvent::DEFAULT_REGION]) && (count($blocks[LoginFormBuildEvent::DEFAULT_REGION]) > 0)) {
265: $this->loginForm($params, $form_state);
266: return;
267: }
268: }
269:
270: $this->auth->login($submit_event, $form_state);
271:
272: $this->f3->reroute('/' . $params['destination']);
273: }
274:
275: /**
276: * Attempts to log out a user and returns to the login form.
277: *
278: * @param \Base $f3
279: * @param array<string, mixed> $params
280: * @return void
281: */
282: public function logout($f3, $params) {
283: $params['destination'] = (isset($params['*'])) ? $params['*'] : '';
284: $this->f3->set('PARAMS.destination', $params['destination']);
285:
286: // Require HTTPS, redirect if necessary
287: $this->checkHttps('redirect', true);
288:
289: // Check if user is logged in
290: $user = $this->auth->getUser();
291: if ($user == null) {
292: if ($params['destination']) {
293: $this->f3->reroute('/' . $params['destination']);
294: } else {
295: $this->loginForm($params);
296: }
297: }
298:
299: $this->auth->logout();
300:
301: $event = new GenericStoppableEvent('post_logout');
302: \Events::instance()->dispatch($event);
303:
304: if (!$event->isPropagationStopped()) {
305: if ($params['destination']) {
306: $this->f3->reroute('/' . $params['destination']);
307: } else {
308: $this->f3->set('message', $this->f3->get('intl.core.auth.logout_success'));
309: $this->loginForm($params);
310: }
311: }
312: }
313:
314: /**
315: * Displays a user login or a login verification form.
316: *
317: * The following common additional variables are stored in the form state (`$form_state`):
318: *
319: * - `auth_level` - if login is successful, the authentication level
320: * - `auth_skip_activity` - true if the login should not be recorded in the activity
321: * log
322: * - `cancel` - if the login form is cancellable, a string to identify the module to
323: * handle the cancellation event
324: * - `mode` - one of the AuthManager::MODE constants to determine the mode of the form
325: * - `modules` - if login is successful, an array of authentication modules
326: * - `uid` - the user ID entered by the user or requested for verification
327: * - `verify_forms` - if mode is AuthManager::MODE_VERIFY, an array of UI blocks containing
328: * the verificationinterface
329: *
330: * Authentication modules may define additional variables in the form state.
331: *
332: * @param array<string, mixed> $params the F3 parameters
333: * @param FormState $form_state|null the form state
334: * @return void
335: */
336: public function loginForm($params = [ 'destination' => null ], $form_state = null) {
337: $tpl = Template::instance();
338: $matrix = \Matrix::instance();
339: $config = $this->f3->get('config');
340: if ($form_state == null) $form_state = new FormState([ 'mode' => AuthManager::MODE_IDENTIFY_USER ]);
341:
342: // 1. Check for HTTPS
343: $this->checkHttps('redirect', true);
344:
345: // 2. Build the forms
346: if (($form_state['mode'] == AuthManager::MODE_VERIFY) && isset($form_state['verify_forms'])) {
347: $forms = [ 'default' => $form_state['verify_forms'] ];
348: unset($form_state['verify_forms']);
349: } else {
350: $event = new LoginFormBuildEvent($form_state, 'login_form_build');
351:
352: if ($form_state['mode'] != AuthManager::MODE_IDENTIFY_USER) {
353: $this->f3->set('uid', $form_state['uid']);
354: $this->f3->set('allow_change_uid', ($form_state['mode'] != AuthManager::MODE_REENTER_CREDENTIALS));
355: $event->showUIDBlock();
356: }
357:
358: \Events::instance()->dispatch($event);
359: $forms = $event->getBlocksGroupedByRegion();
360: $tpl->mergeAttachments($event);
361:
362: $uid_autocomplete = $event->getUIDAutocompleteValues();
363: if (count($uid_autocomplete) > 0) $this->f3->set('uid_autocomplete', $uid_autocomplete);
364: }
365:
366: $block_additional_data = [];
367: $switchable = false;
368: foreach ($forms as $region => $blocks) {
369: foreach ($blocks as $block) {
370: $block_additional_data[$block['id']] = $block['additional'];
371: }
372: }
373: if (!isset($forms['default']) || count($forms['default']) == 0) {
374: $active_block_id = null;
375: } else {
376: $active_block_id = $forms['default'][0]['id'];
377: $switchable = (($form_state['mode'] != AuthManager::MODE_IDENTIFY_USER) && count($forms['default']) > 1);
378: if ($switchable && $this->f3->exists('POST.active_block')) {
379: $block_ids = $matrix->pick($forms['default'], 'id');
380: if (in_array($this->f3->get('POST.active_block'), $block_ids)) $active_block_id = $this->f3->get('POST.active_block');
381: }
382: }
383: $this->f3->set('switchable', $switchable);
384: if ((isset($forms['secondary']) && (count($forms['secondary']) > 0)) || $switchable) {
385: $this->f3->set('show_divider', true);
386: }
387:
388: $this->f3->set('forms', $forms);
389:
390: // 3. Build the buttons and security messaging
391: switch ($form_state['mode']) {
392: case AuthManager::MODE_REENTER_CREDENTIALS:
393: case AuthManager::MODE_CREDENTIALS:
394: case AuthManager::MODE_IDENTIFY_USER:
395: $this->f3->set('submit_button', $this->f3->get('intl.common.login'));
396: $this->f3->set('title', $this->f3->get('intl.common.login'));
397: break;
398: case AuthManager::MODE_VERIFY:
399: if (count($forms['default']) == 0) return; // Nothing to verify
400: $this->f3->set('submit_button', $this->f3->get('intl.common.verify'));
401: $this->f3->set('title', $this->f3->get('intl.common.verify'));
402: // TODO - $user['auth_login_last_active_block'][AuthManager::MODE_VERIFY]
403: }
404:
405: if (isset($form_state['cancel'])) {
406: $this->f3->set('cancellable', true);
407: }
408:
409: // 4. We can't use SecurityToken::BIND_SESSION here because the PHP session is not
410: // yet stable
411: $token = new SecurityToken();
412: $tk = $token->generate('login', SecurityToken::OPTION_NONCE);
413: $this->f3->set('tk', $tk);
414:
415: $fs = $token->generate($form_state->encode());
416: $this->f3->set('fs', $fs);
417: if (isset($params['destination'])) $this->f3->set('destination', $params['destination']);
418: $this->f3->set('login_app_options', [
419: 'fs' => $fs,
420: 'tk' => $tk,
421: 'mode' => $form_state['mode'],
422: 'identifyUrl' => $this->getCanonicalURL('@auth_identify', '', 'https'),
423: 'activeBlock' => $active_block_id,
424: 'blocks' => $block_additional_data
425: ]);
426: $this->f3->set('page_class', 'is-dialog-page');
427: $this->f3->set('layout', 'auth_login.html');
428:
429: header('X-Frame-Options: DENY');
430: print $tpl->render('page.html');
431: }
432: }
433:
434: ?>