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\Auth\AuthManager;
27: use SimpleID\Crypt\BigNum;
28: use SimpleID\Crypt\Random;
29: use SimpleID\Crypt\SecurityToken;
30: use SimpleID\Store\StoreManager;
31: use SimpleID\Util\Events\BaseDataCollectionEvent;
32: use SimpleID\Util\Events\UIBuildEvent;
33: use SimpleID\Util\Forms\FormBuildEvent;
34: use SimpleID\Util\Forms\FormSubmitEvent;
35: use SimpleID\Util\UI\Template;
36:
37: /**
38: * An authentication scheme module that provides two-factor authentication
39: * based on a RFC 6238 Time-Based One-Time Password (TOTP).
40: */
41: class OTPAuthSchemeModule extends AuthSchemeModule {
42:
43: static function init($f3) {
44: $f3->route('GET|POST /auth/otp', 'SimpleID\Auth\OTPAuthSchemeModule->setup');
45: }
46:
47: /**
48: * Displays the page used to set up login verification using one-time
49: * passwords.
50: *
51: * @return void
52: */
53: public function setup() {
54: $auth = AuthManager::instance();
55: $store = StoreManager::instance();
56: /** @var \SimpleID\Models\User $user */
57: $user = $auth->getUser();
58:
59: $tpl = Template::instance();
60: $token = new SecurityToken();
61:
62: // Require HTTPS, redirect if necessary
63: $this->checkHttps('redirect', true);
64:
65: if (!$auth->isLoggedIn()) {
66: $this->f3->reroute('/my/dashboard');
67: return;
68: }
69:
70: if ($this->f3->get('POST.op') == 'disable') {
71: if (($this->f3->exists('POST.tk') === false) || (!$token->verify($this->f3->get('POST.tk'), 'otp'))) {
72: $this->f3->set('message', $this->f3->get('intl.common.invalid_tk'));
73: $this->f3->mock('GET /my/dashboard');
74: return;
75: }
76:
77: if (isset($user['otp'])) {
78: unset($user['otp']);
79: $store->saveUser($user);
80:
81: $event = new CredentialEvent($user, CredentialEvent::CREDENTIAL_DELETED_EVENT, self::class);
82: \Events::instance()->dispatch($event);
83: }
84: $this->f3->set('message', $this->f3->get('intl.core.auth_otp.disable_success'));
85: $this->f3->mock('GET /my/dashboard');
86: return;
87: } elseif ($this->f3->get('POST.op') == 'verify') {
88: $params = $token->getPayload($this->f3->get('POST.otp_params'));
89: $params['secret'] = base64_decode($params['secret']);
90: $this->f3->set('otp_params', $this->f3->get('POST.otp_params'));
91:
92: if (($this->f3->exists('POST.tk') === false) || (!$token->verify($this->f3->get('POST.tk'), 'otp'))) {
93: $this->f3->set('message', $this->f3->get('intl.common.invalid_tk'));
94: $this->f3->mock('GET /my/dashboard');
95: return;
96: } elseif (($this->f3->exists('POST.otp') === false) || ($this->f3->get('POST.otp') == '')) {
97: $this->f3->set('message', $this->f3->get('intl.core.auth_otp.missing_otp'));
98: } elseif ($this->verifyOTP($params, $this->f3->get('POST.otp'), 10) === false) {
99: $this->f3->set('message', $this->f3->get('intl.core.auth_otp.invalid_otp'));
100: } else {
101: $user['otp'] = $params;
102: $store->saveUser($user);
103:
104: $event = new CredentialEvent($user, CredentialEvent::CREDENTIAL_ADDED_EVENT, self::class);
105: \Events::instance()->dispatch($event);
106:
107: $this->f3->set('message', $this->f3->get('intl.core.auth_otp.enable_success'));
108: $this->f3->mock('GET /my/dashboard');
109: return;
110: }
111: } else {
112: $rand = new Random();
113:
114: $params = [
115: 'type' => 'totp',
116: 'secret' => $rand->bytes(10),
117: 'algorithm' => 'sha1',
118: 'digits' => 6,
119: 'period' => 30,
120: 'drift' => 0,
121: 'remember' => []
122: ];
123: // SecurityToken requires everything to be UTF-8
124: $params_token = $params;
125: $params_token['secret'] = base64_encode($params['secret']);
126: $this->f3->set('otp_params', $token->generate($params_token, SecurityToken::OPTION_BIND_SESSION));
127: }
128:
129: $secret = new BigNum($params['secret'], 256);
130: $base32 = $secret->val(32);
131: assert($base32 != false);
132: $code = strtr($base32, '0123456789abcdefghijklmnopqrstuv', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567');
133: $code = str_repeat('A', 16 - strlen($code)) . $code;
134: for ($i = 0; $i < strlen($code); $i += 4) {
135: $this->f3->set('secret' . ($i + 1), substr($code, $i, 4));
136: }
137:
138: $url = 'otpauth://totp/SimpleID:' . rawurlencode($user['uid']) . '?issuer=SimpleID&secret=' . $code . '&digits=' . $params['digits'] . '&period=' . $params['period'];
139: $this->f3->set('qr', addslashes($url));
140:
141: $this->f3->set('otp_recovery_url', 'http://simpleid.org/docs/2/common-problems/#otp');
142:
143: $this->f3->set('tk', $token->generate('otp', SecurityToken::OPTION_BIND_SESSION));
144:
145:
146: $this->f3->set('page_class', 'is-dialog-page');
147: $this->f3->set('title', $this->f3->get('intl.core.auth_otp.otp_title'));
148: $this->f3->set('layout', 'auth_otp_setup.html');
149:
150: header('X-Frame-Options: DENY');
151: print $tpl->render('page.html');
152: }
153:
154: /**
155: * Returns the dashboard OTP block.
156: *
157: * @param UIBuildEvent $event the event to collect
158: * the dashboard OTP block
159: * @return void
160: */
161: public function onDashboardBlocks(UIBuildEvent $event) {
162: $auth = AuthManager::instance();
163: $user = $auth->getUser();
164:
165: $base_path = $this->f3->get('base_path');
166:
167: $token = new SecurityToken();
168: $tk = $token->generate('otp', SecurityToken::OPTION_BIND_SESSION);
169:
170: $html = '<p>' . $this->f3->get('intl.core.auth_otp.about_otp') . '</p>';
171:
172: if (isset($user['otp'])) {
173: $html .= '<p>' . $this->f3->get('intl.core.auth_otp.otp_enabled_block') . '</p>';
174: $html .= '<form action="' . $base_path . 'auth/otp" method="post" enctype="application/x-www-form-urlencoded"><input type="hidden" name="tk" value="'. $tk . '"/>';
175: $html .= '<button type="submit" name="op" value="disable">' . $this->f3->get('intl.common.disable') . '</button></form>';
176: } else {
177: $html .= '<p>' . $this->f3->get('intl.core.auth_otp.otp_disabled_block') . '</p>';
178: $html .= '<form action="' . $base_path . 'auth/otp" method="post" enctype="application/x-www-form-urlencoded"><input type="hidden" name="tk" value="'. $tk . '"/>';
179: $html .= '<button type="submit" name="op" value="enable">' . $this->f3->get('intl.common.enable') . '</button></form>';
180: }
181:
182: $event->addBlock('otp', $html, 0, [
183: 'title' => $this->f3->get('intl.core.auth_otp.otp_title')
184: ]);
185: }
186:
187: /**
188: * @param FormBuildEvent $event
189: * @return void
190: */
191: public function onLoginFormBuild(FormBuildEvent $event) {
192: $form_state = $event->getFormState();
193:
194: if ($form_state['mode'] == AuthManager::MODE_VERIFY) {
195: $auth = AuthManager::instance();
196: $store = StoreManager::instance();
197:
198: /** @var \SimpleID\Models\User $test_user */
199: $test_user = $store->loadUser($form_state['uid']);
200: if (!isset($test_user['otp'])) return;
201: if ($test_user['otp']['type'] == 'recovery') return;
202:
203: $uaid = $auth->assignUAID();
204: if (in_array($uaid, $test_user['otp']['remember'])) return;
205:
206: $tpl = Template::instance();
207:
208: // Note this is called from user_login(), so $_POST is always filled
209: $this->f3->set('otp_recovery_url', 'http://simpleid.org/docs/2/common_problems/#otp');
210:
211: $this->f3->set('submit_button', $this->f3->get('intl.common.verify'));
212:
213: $event->addBlock('auth_otp', $tpl->render('auth_otp.html', false), 0);
214: }
215: }
216:
217: /**
218: * @param FormSubmitEvent $event
219: * @return void
220: */
221: public function onLoginFormValidate(FormSubmitEvent $event) {
222: $form_state = $event->getFormState();
223:
224: if ($form_state['mode'] == AuthManager::MODE_VERIFY) {
225: if ($this->f3->exists('POST.otp.otp') === false) {
226: $this->f3->set('message', $this->f3->get('intl.core.auth_otp.missing_otp'));
227: $event->setInvalid();
228: }
229: }
230: }
231:
232: /**
233: * @param LoginFormSubmitEvent $event
234: * @return void
235: */
236: public function onLoginFormSubmit(LoginFormSubmitEvent $event) {
237: $form_state = $event->getFormState();
238:
239: if ($form_state['mode'] == AuthManager::MODE_VERIFY) {
240: $store = StoreManager::instance();
241:
242: $uid = $form_state['uid'];
243: /** @var \SimpleID\Models\User $test_user */
244: $test_user = $store->loadUser($form_state['uid']);
245: $params = $test_user['otp'];
246:
247: if ($this->verifyOTP($params, $this->f3->get('POST.otp.otp'), 10) === false) {
248: $this->f3->set('message', $this->f3->get('intl.core.auth_otp.invalid_otp'));
249: $event->setInvalid();
250: return;
251: }
252:
253: if ($this->f3->get('POST.otp.remember') == '1') $form_state['otp_remember'] = 1;
254:
255: $test_user['otp'] = $params;
256: $store->saveUser($test_user); // Save the drift
257:
258: $event->addAuthModuleName(self::class);
259: $event->setUser($test_user);
260: $event->setAuthLevel(AuthManager::AUTH_LEVEL_VERIFIED);
261: }
262: }
263:
264: /**
265: * @see SimpleID\Auth\LoginEvent
266: * @return void
267: */
268: public function onLoginEvent(LoginEvent $event) {
269: $user = $event->getUser();
270: $level = $event->getAuthLevel();
271: $form_state = $event->getFormState();
272:
273: $auth = AuthManager::instance();
274: $store = StoreManager::instance();
275:
276: if (($level >= AuthManager::AUTH_LEVEL_VERIFIED) && isset($form_state['otp_remember']) && ($form_state['otp_remember'] == 1)) {
277: $uaid = $auth->assignUAID();
278: $remember = $user['otp']['remember'];
279: $remember[] = $uaid;
280: $user->set('otp.remember', array_unique($remember));
281:
282: $store->saveUser($user);
283: }
284: }
285:
286: /**
287: * Verifies a one time password (OTP) specified by the user.
288: *
289: * This function compares an OTP supplied by a user with the OTP
290: * calculated based on the current time and the parameters of the
291: * algorithm. The parameters, such as the secret key, are supplied
292: * using in $params. These parameters are typically stored for each
293: * user in the user store.
294: *
295: * To allow for clocks going out of sync, the current time will be
296: * by a number (in time steps) specified in $params['drift']. If
297: * the OTP supplied by the user is accepted, $params['drift'] will
298: * be also be updated with the latest difference.
299: *
300: * To allow for network delay, the function will accepts OTPs which
301: * is a number of time steps away from the OTP calculated from the
302: * adjusted time. The maximum number of time steps is specified in
303: * the $max_drift parameter.
304: *
305: * @param array<string, mixed> &$params the OTP parameters stored
306: * @param string $code the OTP supplied by the user
307: * @param int $max_drift the maximum drift allowed for network delay, in
308: * time steps
309: * @return bool whether the OTP supplied matches the OTP generated based on
310: * the specified parameters, within the maximum drift
311: */
312: protected function verifyOTP(&$params, $code, $max_drift = 1) {
313: $time = time();
314:
315: $test_code = $this->totp($params['secret'], $time, $params['period'], $params['drift'], $params['algorithm'], $params['digits']);
316:
317: if ($test_code == intval($code)) return true;
318:
319: for ($i = -$max_drift; $i <= $max_drift; $i++) {
320: $test_code = $this->totp($params['secret'], $time, $params['period'], $params['drift'] + $i, $params['algorithm'], $params['digits']);
321: if ($test_code == intval($code)) {
322: $params['drift'] = $i;
323: return true;
324: }
325: }
326: return false;
327:
328: }
329:
330: /**
331: * Calculates a Time-Based One-Time Password (TOTP) based on RFC 6238.
332: *
333: * This function returns an integer calculated from the TOTP algorithm.
334: * The returned integer may need to be zero-padded to return a string
335: * with the required number of digits
336: *
337: * @param string $secret the shared secret as a binary string
338: * @param int $time the time to use in the HOTP algorithm. If NULL, the
339: * current time is used
340: * @param int $period the time step in seconds
341: * @param int $drift the number of time steps to be added to the time to
342: * adjust for transmission delay
343: * @param string $algorithm the hashing algorithm as supported by
344: * the hash_hmac() function
345: * @param int $digits the number of digits in the one-time password
346: * @return int the one-time password
347: * @link http://tools.ietf.org/html/rfc6238
348: */
349: public function totp($secret, $time = NULL, $period = 30, $drift = 0, $algorithm = 'sha1', $digits = 6) {
350: if ($time == NULL) $time = time();
351: $counter = floor($time / $period) + $drift;
352: $data = pack('NN', 0, $counter);
353: return $this->hotp($secret, $data, $algorithm, $digits);
354: }
355:
356: /**
357: * Calculates a HMAC-Based One-Time Password (HOTP) based on RFC 4226.
358: *
359: * This function returns an integer calculated from the HOTP algorithm.
360: * The returned integer may need to be zero-padded to return a string
361: * with the required number of digits
362: *
363: * @param string $secret the shared secret as a binary string
364: * @param string $data the counter value as a 64 bit in
365: * big endian encoding
366: * @param string $algorithm the hashing algorithm as supported by
367: * the hash_hmac() function
368: * @param int $digits the number of digits in the one-time password
369: * @return int the one-time password
370: * @link http://tools.ietf.org/html/rfc4226
371: */
372: public function hotp($secret, $data, $algorithm = 'sha1', $digits = 6) {
373: // unpack produces a 1-based array, we use array_merge to convert it to 0-based
374: $unpacked = unpack('C*', hash_hmac(strtolower($algorithm), $data, $secret, true));
375: assert($unpacked != false);
376: $hmac = array_merge($unpacked);
377: $offset = $hmac[19] & 0xf;
378: $code = ($hmac[$offset + 0] & 0x7F) << 24 |
379: ($hmac[$offset + 1] & 0xFF) << 16 |
380: ($hmac[$offset + 2] & 0xFF) << 8 |
381: ($hmac[$offset + 3] & 0xFF);
382: return $code % pow(10, $digits);
383: }
384:
385: /**
386: * @return void
387: */
388: public function onUserSecretDataPaths(BaseDataCollectionEvent $event) {
389: $event->addResult([ 'otp.secret', 'otp.drift' ]);
390: }
391: }
392: ?>
393: