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 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('GET|POST /auth/login', 'SimpleID\Auth\AuthModule->login');
50: $f3->route('GET|POST @auth_login: /auth/login/*', 'SimpleID\Auth\AuthModule->login');
51: $f3->route('GET /auth/logout', 'SimpleID\Auth\AuthModule->logout');
52: $f3->route('GET @auth_logout: /auth/logout/*', 'SimpleID\Auth\AuthModule->logout');
53: }
54:
55: public function __construct() {
56: parent::__construct();
57: $this->auth = AuthManager::instance();
58: }
59:
60: /**
61: * FatFree Framework event handler.
62: *
63: * This module does not use the default event handler provided by {@link SimpleID\Module},
64: * as it needs to disable the automatic authentication.
65: *
66: */
67: public function beforeroute() {
68: $this->auth->initSession();
69: $this->auth->initUser(false);
70: }
71:
72: /**
73: * Attempts to log in a user, using the credentials specified in the
74: * HTTP request.
75: *
76: * @param \Base $f3
77: * @param array<string, mixed> $params
78: * @return void
79: */
80: public function login($f3, $params) {
81: $dispatcher = \Events::instance();
82:
83: $params['destination'] = (isset($params['*'])) ? $params['*'] : '';
84: $this->f3->set('PARAMS.destination', $params['destination']);
85:
86: $token = new SecurityToken();
87:
88: // Require HTTPS or return an error
89: $this->checkHttps('error', true);
90:
91: if (($this->f3->exists('POST.fs') === false)) {
92: $form_state = new FormState([ 'mode' => AuthManager::MODE_CREDENTIALS ]);
93: if (in_array($this->f3->get('GET.mode'), [ AuthManager::MODE_VERIFY, AuthManager::MODE_REENTER_CREDENTIALS ])) {
94: $form_state['mode'] = $this->f3->get('GET.mode');
95: }
96: $this->loginForm($params, $form_state);
97: return;
98: }
99:
100: $form_state = FormState::decode($token->getPayload($this->f3->get('POST.fs')));
101: if (count($form_state) == 0) $form_state['mode'] = AuthManager::MODE_CREDENTIALS;
102: $mode = $form_state['mode'];
103: if (!in_array($mode, [ AuthManager::MODE_CREDENTIALS, AuthManager::MODE_REENTER_CREDENTIALS, AuthManager::MODE_VERIFY ])) {
104: $this->f3->set('message', $this->f3->get('intl.core.auth.state_error'));
105: $this->loginForm($params, $form_state);
106: return;
107: }
108:
109: if ($this->f3->exists('POST.tk') === false) {
110: if ($params['destination']) {
111: // User came from a log in form.
112: $this->f3->set('message', $this->f3->get('intl.core.auth.missing_tk'));
113: }
114: $this->loginForm($params, $form_state);
115: return;
116: }
117:
118: if (!$token->verify($this->f3->get('POST.tk'), 'login')) {
119: $this->logger->log(LogLevel::WARNING, 'Login attempt: Security token ' . $this->f3->get('POST.tk') . ' invalid.');
120: $this->f3->set('message', $this->f3->get('intl.core.auth.state_error'));
121: $this->loginForm($params, $form_state);
122: return;
123: }
124:
125: if ($this->f3->exists('POST.op') && $this->f3->get('POST.op') == 'cancel') {
126: $cancel_event = new FormSubmitEvent($form_state, 'login_form_cancel');
127:
128: $dispatcher->dispatch($cancel_event);
129:
130: // Listeners should call stopPropagation if it has processed successfully
131: if (!$cancel_event->isPropagationStopped()) {
132: $this->fatalError($this->f3->get('intl.core.auth.cancelled'), 400);
133: }
134: return;
135: }
136:
137: // If the user is already logged in, return
138: if (($mode == AuthManager::MODE_CREDENTIALS) && $this->auth->isLoggedIn()) $this->f3->reroute('/');
139:
140: $validate_event = new FormSubmitEvent($form_state, 'login_form_validate');
141: $dispatcher->dispatch($validate_event);
142: if (!$validate_event->isValid()) {
143: $this->f3->set('message', $validate_event->getMessages());
144: $this->loginForm($params, $form_state);
145: return;
146: }
147:
148: $submit_event = new LoginFormSubmitEvent($form_state, 'login_form_submit');
149: $dispatcher->dispatch($submit_event);
150: if (!$submit_event->isValid()) {
151: $this->f3->set('message', $submit_event->getMessages());
152: $this->loginForm($params, $form_state);
153: return;
154: }
155:
156: if ($submit_event->isAuthSuccessful()) {
157: // $submit_event->getUser() can be null when mode is MODE_VERIFY or MODE_REENTER_CREDENTIALS
158: // In these cases $form_state['uid'] would already be populated
159: $test_user = $submit_event->getUser();
160: if ($test_user != null) $form_state['uid'] = $test_user['uid'];
161:
162: $form_state['auth_level'] = $submit_event->getAuthLevel();
163: $form_state['modules'] = $submit_event->getAuthModuleNames();
164: } else {
165: $this->loginForm($params, $form_state);
166: return;
167: }
168:
169: if (!isset($form_state['uid'])) {
170: // No user
171: $this->loginForm($params, $form_state);
172: return;
173: }
174:
175: if ($mode == AuthManager::MODE_CREDENTIALS) {
176: $form_state['mode'] = AuthManager::MODE_VERIFY;
177: $event = new FormBuildEvent($form_state, 'login_form_build');
178:
179: $dispatcher->dispatch($event);
180: if (count($event->getBlocks()) > 0) {
181: $this->loginForm($params, $form_state);
182: return;
183: }
184: }
185:
186: $this->auth->login($submit_event, $form_state);
187:
188: $this->f3->reroute('/' . $params['destination']);
189: }
190:
191: /**
192: * Attempts to log out a user and returns to the login form.
193: *
194: * @param \Base $f3
195: * @param array<string, mixed> $params
196: * @return void
197: */
198: public function logout($f3, $params) {
199: $params['destination'] = (isset($params['*'])) ? $params['*'] : '';
200: $this->f3->set('PARAMS.destination', $params['destination']);
201:
202: // Require HTTPS, redirect if necessary
203: $this->checkHttps('redirect', true);
204:
205: $this->auth->logout();
206:
207: $event = new GenericStoppableEvent('post_logout');
208: \Events::instance()->dispatch($event);
209:
210: if (!$event->isPropagationStopped()) {
211: if ($params['destination']) {
212: $this->f3->reroute('/' . $params['destination']);
213: } else {
214: $this->f3->set('message', $this->f3->get('intl.core.auth.logout_success'));
215: $this->loginForm($params);
216: }
217: }
218: }
219:
220: /**
221: * Displays a user login or a login verification form.
222: *
223: * The following common additional variables are stored in the form state (`$form_state`):
224: *
225: * - `auth_level` - if login is successful, the authentication level
226: * - `auth_skip_activity` - true if the login should not be recorded in the activity
227: * log
228: * - `cancel` - if the login form is cancellable, a string to identify the module to
229: * handle the cancellation event
230: * - `mode` - one of the AuthManager::MODE constants to determine the mode of the form
231: * - `modules` - if login is successful, an array of authentication modules
232: * - `uid` - the user ID entered by the user or requested for verification
233: * - `verify_forms` - if mode is AuthManager::MODE_VERIFY, an array of UI blocks containing
234: * the verificationinterface
235: *
236: * Authentication modules may define additional variables in the form state.
237: *
238: * @param array<string, mixed> $params the F3 parameters
239: * @param FormState $form_state|null the form state
240: * @return void
241: */
242: public function loginForm($params = [ 'destination' => null ], $form_state = null) {
243: $tpl = Template::instance();
244: $config = $this->f3->get('config');
245: if ($form_state == null) $form_state = new FormState([ 'mode' => AuthManager::MODE_CREDENTIALS ]);
246:
247: // 1. Check for HTTPS
248: $this->checkHttps('redirect', true);
249:
250: // 2. Build the forms
251: if (($form_state['mode'] == AuthManager::MODE_VERIFY) && isset($form_state['verify_forms'])) {
252: $forms = $form_state['verify_forms'];
253: unset($form_state['verify_forms']);
254: } else {
255: $event = new FormBuildEvent($form_state, 'login_form_build');
256: \Events::instance()->dispatch($event);
257: $forms = $event->getBlocks();
258: $tpl->mergeAttachments($event);
259: }
260: $this->f3->set('forms', $forms);
261:
262: // 3. Build the buttons and security messaging
263: switch ($form_state['mode']) {
264: case AuthManager::MODE_REENTER_CREDENTIALS:
265: // Follow through
266: $this->f3->set('uid', $form_state['uid']);
267: case AuthManager::MODE_CREDENTIALS:
268: $this->f3->set('submit_button', $this->f3->get('intl.common.login'));
269: $this->f3->set('title', $this->f3->get('intl.common.login'));
270: break;
271: case AuthManager::MODE_VERIFY:
272: if (count($forms) == 0) return; // Nothing to verify
273: $this->f3->set('submit_button', $this->f3->get('intl.common.verify'));
274: $this->f3->set('title', $this->f3->get('intl.common.verify'));
275: }
276:
277: if (isset($form_state['cancel'])) {
278: $this->f3->set('cancellable', true);
279: }
280:
281: // 4. We can't use SecurityToken::BIND_SESSION here because the PHP session is not
282: // yet stable
283: $token = new SecurityToken();
284: $this->f3->set('tk', $token->generate('login', SecurityToken::OPTION_NONCE));
285:
286: $this->f3->set('fs', $token->generate($form_state->encode()));
287: if (isset($params['destination'])) $this->f3->set('destination', $params['destination']);
288: $this->f3->set('page_class', 'is-dialog-page');
289: $this->f3->set('layout', 'auth_login.html');
290:
291: header('X-Frame-Options: DENY');
292: print $tpl->render('page.html');
293: }
294: }
295:
296: ?>