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