1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 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\Auth\AuthManager;
26: use SimpleID\Crypt\Random;
27: use SimpleID\Crypt\SecurityToken;
28: use SimpleID\Store\StoreManager;
29: use SimpleID\Util\Events\BaseDataCollectionEvent;
30: use SimpleID\Util\Events\UIBuildEvent;
31: use SimpleID\Util\Forms\FormBuildEvent;
32: use SimpleID\Util\Forms\FormSubmitEvent;
33: use SimpleID\Util\UI\Template;
34:
35: /**
36: * An authentication scheme module that provides account recovery through
37: * recovery codes.
38: */
39: class RecoveryCodeAuthSchemeModule extends AuthSchemeModule {
40:
41: const RECOVERY_CODE_COUNT = 5;
42: const RECOVERY_CODE_MAX_ATTEMPTS = 5;
43: const RECOVERY_CODE_TIMEOUT = 3600;
44:
45: const PBKDF2_ALGORITHM = 'sha256';
46: const PBKDF2_ITERATIONS = 600000;
47:
48: static function init($f3) {
49: $f3->route('POST @auth_recovery: /auth/recovery', 'SimpleID\Auth\RecoveryCodeAuthSchemeModule->setupRecoveryCodes');
50: }
51:
52: /**
53: * Displays the page used to generate recovery codes.
54: *
55: * @return void
56: */
57: public function setupRecoveryCodes() {
58: $auth = AuthManager::instance();
59: $store = StoreManager::instance();
60: /** @var \SimpleID\Models\User $user */
61: $user = $auth->getUser();
62:
63: $tpl = Template::instance();
64: $token = new SecurityToken();
65:
66: // Require HTTPS, redirect if necessary
67: $this->checkHttps('redirect', true);
68:
69: if (!$auth->isLoggedIn()) {
70: $this->f3->reroute('/my/dashboard');
71: return;
72: }
73:
74: // We check POST.active_block to see whether we are coming directly
75: // from an authentication module
76: if ($this->f3->exists('POST.active_block') && ($this->f3->get('POST.active_block') == 'auth_recovery') && $this->f3->get('POST.op') == 'continue') {
77: $this->f3->set('message', $this->f3->get('intl.core.auth_recovery.generate_success'));
78: $this->f3->mock('GET /my/dashboard');
79: return;
80: } elseif (($this->f3->exists('POST.active_block') && ($this->f3->get('POST.active_block') == 'auth_recovery') && $this->f3->get('POST.op') == 'reset')
81: || !$user->exists('recovery.recovery_codes') || (count($user->get('recovery.recovery_codes')) == 0)) {
82:
83: if ($this->isBlockActive('auth_recovery') && (($this->f3->exists('POST.tk') === false) || (!$token->verify($this->f3->get('POST.tk'), 'recovery')))) {
84: $this->f3->set('message', $this->f3->get('intl.common.invalid_tk'));
85: $this->f3->mock('GET /my/dashboard');
86: return;
87: }
88:
89: $rand = new Random();
90: $recovery_codes = [];
91: $encoded_list = [];
92:
93: for ($i = 0; $i < self::RECOVERY_CODE_COUNT; $i++) {
94: $code = $rand->password(32, 4, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567');
95: $encoded_list[] = $this->encodeRecoveryCode($code);
96: $recovery_codes[] = $code;
97: }
98:
99: $user->set('recovery.recovery_codes', $encoded_list);
100: $store->saveUser($user);
101:
102: $this->f3->set('recovery_codes', $recovery_codes);
103: } elseif (!$this->f3->exists('POST.active_block') || ($this->f3->get('POST.active_block') != 'auth_recovery')) {
104: // This is coming from another authentication scheme module.
105: // Given recovery codes have already been set, we silently redirect
106: // back to the dashboard
107: $this->f3->mock('GET /my/dashboard');
108: return;
109: }
110:
111: $this->f3->set('tk', $token->generate('recovery', SecurityToken::OPTION_BIND_SESSION));
112:
113: $this->f3->set('page_class', 'is-dialog-page');
114: $this->f3->set('title', $this->f3->get('intl.core.auth_recovery.recovery_title'));
115: $this->f3->set('layout', 'auth_recovery_setup.html');
116:
117: header('X-Frame-Options: DENY');
118: print $tpl->render('page.html');
119: }
120:
121: /**
122: * Returns the dashboard recovery code block.
123: *
124: * @param UIBuildEvent $event the event to collect
125: * the dashboard recovery code block
126: * @return void
127: */
128: public function onDashboardBlocks(UIBuildEvent $event) {
129: $auth = AuthManager::instance();
130: $user = $auth->getUser();
131:
132: $base_path = $this->f3->get('base_path');
133:
134: $token = new SecurityToken();
135: $tk = $token->generate('recovery', SecurityToken::OPTION_BIND_SESSION);
136:
137: $html = '<p>' . $this->f3->get('intl.core.auth_recovery.about_recovery') . '</p>';
138:
139: if (isset($user['recovery'])) {
140: $html .= '<p>' . $this->f3->get('intl.core.auth_recovery.recovery_codes_generated') . '</p>';
141: $html .= '<form action="' . $base_path . 'auth/recovery" method="post" enctype="application/x-www-form-urlencoded"><input type="hidden" name="tk" value="'. $tk . '"/><input type="hidden" name="active_block" value="auth_recovery"/>';
142: $html .= '<button type="submit" name="op" value="reset">' . $this->f3->get('intl.common.reset') . '</button></form>';
143: } else {
144: $html .= '<p>' . $this->f3->get('intl.core.auth_recovery.recovery_codes_not_generated') . '</p>';
145: $html .= '<form action="' . $base_path . 'auth/recovery" method="post" enctype="application/x-www-form-urlencoded"><input type="hidden" name="tk" value="'. $tk . '"/><input type="hidden" name="active_block" value="auth_recovery"/>';
146: $html .= '<button type="submit" name="op" value="reset">' . $this->f3->get('intl.core.auth_recovery.generate_recovery_code_button') . '</button></form>';
147: }
148:
149: $event->addBlock('recovery', $html, 10, [
150: 'title' => $this->f3->get('intl.core.auth_recovery.recovery_title')
151: ]);
152: }
153:
154: /**
155: * @param FormBuildEvent $event
156: * @return void
157: */
158: public function onLoginFormBuild(FormBuildEvent $event) {
159: $form_state = $event->getFormState();
160:
161: if ($form_state['mode'] == AuthManager::MODE_VERIFY) {
162: $auth = AuthManager::instance();
163: $store = StoreManager::instance();
164:
165: /** @var \SimpleID\Models\User $test_user */
166: $test_user = $store->loadUser($form_state['uid']);
167: if (!$test_user->exists('recovery.recovery_codes') || (count($test_user->get('recovery.recovery_codes')) == 0)) return;
168:
169: $tpl = Template::instance();
170:
171: $event->addBlock('auth_recovery', $tpl->render('auth_recovery.html', false), 10, ['title' => $this->f3->get('intl.core.auth_recovery.verify_block_title')]);
172: }
173: }
174:
175: /**
176: * @param FormSubmitEvent $event
177: * @return void
178: */
179: public function onLoginFormValidate(FormSubmitEvent $event) {
180: $form_state = $event->getFormState();
181:
182: if (($form_state['mode'] == AuthManager::MODE_VERIFY) && $this->isBlockActive('auth_recovery')) {
183: if ($this->f3->exists('POST.recovery.code') === false) {
184: $event->addMessage($this->f3->get('intl.core.auth_recovery.missing_code'));
185: $event->setInvalid();
186: }
187: }
188: }
189:
190: /**
191: * @param LoginFormSubmitEvent $event
192: * @return void
193: */
194: public function onLoginFormSubmit(LoginFormSubmitEvent $event) {
195: // Increment $form_state time and attempts
196:
197: $form_state = $event->getFormState();
198:
199: if (($form_state['mode'] == AuthManager::MODE_VERIFY) && $this->isBlockActive('auth_recovery')) {
200: if ($form_state['recovery_attempts'] >= self::RECOVERY_CODE_MAX_ATTEMPTS) {
201: if (time() > $form_state['recovery_attempt_time'] + self::RECOVERY_CODE_TIMEOUT) {
202: $form_state['recovery_attempts'] = 0;
203: } else {
204: $event->addMessage($this->f3->get('intl.core.auth_recovery.too_many_attempts'));
205: $event->setInvalid();
206: return;
207: }
208: }
209:
210: $store = StoreManager::instance();
211:
212: $uid = $form_state['uid'];
213: /** @var \SimpleID\Models\User $test_user */
214: $test_user = $store->loadUser($form_state['uid']);
215:
216: if ($test_user->exists('recovery.recovery_codes') === false) {
217: $event->addMessage($this->f3->get('intl.core.auth_recovery.invalid_code'));
218: $event->setInvalid();
219: return;
220: }
221:
222: $encoded_list = $test_user->get('recovery.recovery_codes');
223:
224: // To mitigate against timing attacks, we check the codes at least RECOVERY_CODE_COUNT
225: // times
226: $count = max(count($encoded_list), self::RECOVERY_CODE_COUNT);
227: $valid_code_index = -1;
228:
229: for ($i = 0; $i < $count; $i++) {
230: $encoded = $encoded_list[$i % count($encoded_list)];
231: if ($this->verifyRecoveryCode($this->f3->get('POST.recovery.code'), $encoded)) $valid_code_index = $i;
232: }
233:
234: if ($valid_code_index == -1) {
235: if (isset($form_state['recovery_attempts'])) {
236: $form_state['recovery_attempts'] += 1;
237: } else {
238: $form_state['recovery_attempts'] = 1;
239: }
240: $form_state['recovery_attempt_time'] = time();
241: $event->addMessage($this->f3->get('intl.core.auth_recovery.invalid_code'));
242: $event->setInvalid();
243: return;
244: }
245:
246: // Remove successful code from list and save
247: array_splice($encoded_list, $valid_code_index, 1);
248: $test_user->set('recovery.recovery_codes', $encoded_list);
249: $store->saveUser($test_user); // Save the drift
250:
251: $event->addAuthModuleName(self::class);
252: $event->setUser($test_user);
253: $event->setAuthLevel(AuthManager::AUTH_LEVEL_VERIFIED);
254:
255: $event->addMessage($this->f3->get('intl.core.auth_recovery.code_used'));
256: }
257: }
258:
259: /**
260: * Hashes and encodes a recovery code using PBKDF2.
261: *
262: * @param string $code the recovery code
263: * @return string the encoded hash value
264: */
265: protected function encodeRecoveryCode(#[\SensitiveParameter] string $code): string {
266: $rand = new Random();
267: $salt = $rand->bytes(32);
268: $params = [ 'f' => self::PBKDF2_ALGORITHM, 'c' => self::PBKDF2_ITERATIONS ];
269: $hash = hash_pbkdf2(strval($params['f']), $code, $salt, intval($params['c']), 0, true);
270:
271: return '$pbkdf2$' . http_build_query($params) . '$' . base64_encode($hash) . '$' . base64_encode($salt);
272: }
273:
274: /**
275: * Verifies whether a specified recovery code matches the specified encode value.
276: *
277: * @param string $code the recovery code to verify
278: * @param string $encoded the encoded value
279: * @return bool whether the recovery code supplied matches the specified encoded
280: * value
281: */
282: protected function verifyRecoveryCode(#[\SensitiveParameter] string $code, string $encoded): bool {
283: list($dummy, $prefix, $content) = explode('$', $encoded, 3);
284: if ($prefix != 'pbkdf2') return false;
285:
286: $params = [];
287: list($param_string, $hash, $salt) = explode('$', $content, 3);
288: parse_str($param_string, $params);
289: if (!isset($params['f']) || ($params['f'] != self::PBKDF2_ALGORITHM)) return false;
290: // @phpstan-ignore argument.type, argument.type
291: return $this->secureCompare(hash_pbkdf2(strval($params['f']), $code, base64_decode($salt), intval($params['c']), 0, true),
292: base64_decode($hash));
293: }
294:
295: /**
296: * @return void
297: */
298: public function onUserSecretDataPaths(BaseDataCollectionEvent $event) {
299: $event->addResult('recovery.recovery_codes');
300: }
301: }
302: ?>
303: