1<?php
2
3function znote2fa_v2_config(): array {
4 global $config;
5 $cfg = (array)($config['twoFactorV2'] ?? array());
6
7 return array(
8 'enabled' => !empty($cfg['enabled']),
9 'email_otp_enabled' => !array_key_exists('email_otp_enabled', $cfg) || $cfg['email_otp_enabled'] !== false,
10 'force_admins' => !empty($cfg['force_admins']),
11 'recovery_codes_count' => max(1, (int)($cfg['recovery_codes_count'] ?? 10)),
12 'trusted_device_days' => max(0, (int)($cfg['trusted_device_days'] ?? 30)),
13 );
14}
15
16function znote2fa_v2_enabled(): bool {
17 return znote2fa_v2_config()['enabled'];
18}
19
20function znote2fa_row(int $accountId): array {
21 $row = db()->fetchOne("SELECT * FROM `znote_2fa` WHERE `account_id` = ? LIMIT 1;", [$accountId]);
22
23 if (!is_array($row)) {
24 return array(
25 'account_id' => $accountId,
26 'totp_secret' => null,
27 'totp_enabled' => 0,
28 'email_otp_enabled' => 0,
29 'recovery_codes' => null,
30 'session_version' => 1,
31 'updated_at' => 0,
32 );
33 }
34
35 return $row;
36}
37
38function znote2fa_ensure_row(int $accountId): void {
39 db()->execute("
40 INSERT INTO `znote_2fa` (`account_id`, `session_version`, `updated_at`)
41 VALUES (?, 1, ?)
42 ON DUPLICATE KEY UPDATE `account_id` = `account_id`;
43 ", [$accountId, time()]);
44}
45
46function znote2fa_status(int $accountId): array {
47 $row = znote2fa_row($accountId);
48 $recoveryCodes = znote2fa_recovery_decode($row['recovery_codes'] ?? null);
49 $emailOtpEnabled = znote2fa_v2_config()['email_otp_enabled'] && !empty($row['email_otp_enabled']);
50
51 return array(
52 'totp_enabled' => !empty($row['totp_enabled']),
53 'totp_pending' => empty($row['totp_enabled']) && !empty($row['totp_secret']),
54 'email_otp_enabled' => $emailOtpEnabled,
55 'any_enabled' => !empty($row['totp_enabled']) || $emailOtpEnabled,
56 'recovery_remaining' => count($recoveryCodes),
57 );
58}
59
60function znote2fa_totp_start(int $accountId): string {
61 $secret = TokenAuth6238::generateRandomClue(20);
62 znote2fa_ensure_row($accountId);
63 db()->execute("UPDATE `znote_2fa` SET `totp_secret` = ?, `totp_enabled` = 0, `updated_at` = ? WHERE `account_id` = ?;", [$secret, time(), $accountId]);
64
65 return $secret;
66}
67
68function znote2fa_totp_confirm(int $accountId, string $code): bool {
69 $row = znote2fa_row($accountId);
70 if (empty($row['totp_secret']) || !TokenAuth6238::verify($row['totp_secret'], $code)) {
71 return false;
72 }
73
74 return db()->execute("UPDATE `znote_2fa` SET `totp_enabled` = 1, `updated_at` = ? WHERE `account_id` = ?;", [time(), $accountId]) !== false;
75}
76
77function znote2fa_totp_disable(int $accountId): void {
78 db()->execute("UPDATE `znote_2fa` SET `totp_secret` = NULL, `totp_enabled` = 0, `updated_at` = ? WHERE `account_id` = ?;", [time(), $accountId]);
79}
80
81function znote2fa_totp_verify(int $accountId, string $code): bool {
82 $row = znote2fa_row($accountId);
83 return !empty($row['totp_enabled']) && !empty($row['totp_secret']) && TokenAuth6238::verify($row['totp_secret'], $code);
84}
85
86function znote2fa_email_otp_set(int $accountId, bool $enabled): void {
87 $enabled = $enabled && znote2fa_v2_config()['email_otp_enabled'];
88 znote2fa_ensure_row($accountId);
89 db()->execute("UPDATE `znote_2fa` SET `email_otp_enabled` = ?, `updated_at` = ? WHERE `account_id` = ?;", [$enabled ? 1 : 0, time(), $accountId]);
90}
91
92function znote2fa_email_send_code(int $accountId, string $email, string $accountName = ''): bool {
93 global $config;
94
95 $code = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT);
96 $hash = hash('sha256', $code);
97 $expires = time() + 600;
98
99 $stored = db()->execute("
100 INSERT INTO `znote_2fa_email_codes` (`account_id`, `code_hash`, `expires_at`, `attempts`)
101 VALUES (?, ?, ?, 0)
102 ON DUPLICATE KEY UPDATE `code_hash` = VALUES(`code_hash`), `expires_at` = VALUES(`expires_at`), `attempts` = 0;
103 ", [$accountId, $hash, $expires]);
104
105 if ($stored === false) {
106 return false;
107 }
108
109 $mail = new Mail((array)($config['mailserver'] ?? array()));
110 $title = ($config['site_title'] ?? 'ZnoteX') . ' - Login verification code';
111 $html = '<p>Your login verification code is:</p><h2 style="letter-spacing:4px;">' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</h2>'
112 . '<p>It expires in 10 minutes. If you did not try to log in, you can ignore this e-mail.</p>';
113
114 return $mail->sendMail($email, $title, $html, $accountName);
115}
116
117function znote2fa_email_verify_code(int $accountId, string $code): bool {
118 $row = db()->fetchOne("SELECT * FROM `znote_2fa_email_codes` WHERE `account_id` = ? LIMIT 1;", [$accountId]);
119 if (!is_array($row)) {
120 return false;
121 }
122
123 if ((int)$row['expires_at'] < time() || (int)$row['attempts'] >= 5) {
124 db()->execute("DELETE FROM `znote_2fa_email_codes` WHERE `account_id` = ?;", [$accountId]);
125 return false;
126 }
127
128 if (!hash_equals((string)$row['code_hash'], hash('sha256', $code))) {
129 db()->execute("UPDATE `znote_2fa_email_codes` SET `attempts` = `attempts` + 1 WHERE `account_id` = ?;", [$accountId]);
130 return false;
131 }
132
133 db()->execute("DELETE FROM `znote_2fa_email_codes` WHERE `account_id` = ?;", [$accountId]);
134 return true;
135}
136
137function znote2fa_recovery_decode($stored): array {
138 if (!is_string($stored) || $stored === '') {
139 return array();
140 }
141
142 $decoded = json_decode($stored, true);
143 return is_array($decoded) ? $decoded : array();
144}
145
146function znote2fa_recovery_format(string $code): string {
147 $code = strtoupper(preg_replace('/[^A-Z0-9]/i', '', $code) ?? '');
148 return $code;
149}
150
151function znote2fa_recovery_generate(int $accountId, ?int $count = null): array {
152 $count = $count ?? znote2fa_v2_config()['recovery_codes_count'];
153 $plain = array();
154 $hashed = array();
155
156 for ($i = 0; $i < $count; $i++) {
157 $code = strtoupper(bin2hex(random_bytes(5))); // 10 hex chars
158 $plain[] = substr($code, 0, 5) . '-' . substr($code, 5, 5);
159 $hashed[] = password_hash($code, PASSWORD_DEFAULT);
160 }
161
162 znote2fa_ensure_row($accountId);
163 db()->execute("UPDATE `znote_2fa` SET `recovery_codes` = ?, `updated_at` = ? WHERE `account_id` = ?;", [json_encode($hashed), time(), $accountId]);
164
165 return $plain;
166}
167
168function znote2fa_recovery_clear(int $accountId): void {
169 db()->execute("UPDATE `znote_2fa` SET `recovery_codes` = NULL, `updated_at` = ? WHERE `account_id` = ?;", [time(), $accountId]);
170}
171
172function znote2fa_recovery_consume(int $accountId, string $code): bool {
173 $formatted = znote2fa_recovery_format($code);
174 if ($formatted === '') {
175 return false;
176 }
177
178 $row = znote2fa_row($accountId);
179 $hashes = znote2fa_recovery_decode($row['recovery_codes'] ?? null);
180
181 foreach ($hashes as $index => $hash) {
182 if (is_string($hash) && password_verify($formatted, $hash)) {
183 unset($hashes[$index]);
184 db()->execute("UPDATE `znote_2fa` SET `recovery_codes` = ?, `updated_at` = ? WHERE `account_id` = ?;", [json_encode(array_values($hashes)), time(), $accountId]);
185 return true;
186 }
187 }
188
189 return false;
190}
191
192function znote2fa_session_version(int $accountId): int {
193 return (int)(znote2fa_row($accountId)['session_version'] ?? 1);
194}
195
196function znote2fa_logout_all_devices(int $accountId): void {
197 znote2fa_ensure_row($accountId);
198 db()->execute("UPDATE `znote_2fa` SET `session_version` = `session_version` + 1, `updated_at` = ? WHERE `account_id` = ?;", [time(), $accountId]);
199 db()->execute("DELETE FROM `znote_2fa_trusted_devices` WHERE `account_id` = ?;", [$accountId]);
200
201 if (function_exists('znote_hook')) {
202 znote_hook('security.logout_all_devices', array('account_id' => $accountId));
203 }
204}
205
206function znote2fa_trusted_cookie_name(): string {
207 global $config;
208 return (string)($config['session_prefix'] ?? 'znote_') . '2fa_trust';
209}
210
211function znote2fa_trusted_cookie_clear(): void {
212 global $config;
213
214 setcookie(znote2fa_trusted_cookie_name(), '', array(
215 'expires' => time() - 3600,
216 'path' => (string)($config['session']['cookie_path'] ?? '/'),
217 'domain' => (string)($config['session']['cookie_domain'] ?? ''),
218 'secure' => (bool)znote_session_request_is_https(),
219 'httponly' => true,
220 'samesite' => (string)($config['session']['cookie_samesite'] ?? 'Lax'),
221 ));
222 unset($_COOKIE[znote2fa_trusted_cookie_name()]);
223}
224
225function znote2fa_trusted_device_check(int $accountId): bool {
226 $cookie = $_COOKIE[znote2fa_trusted_cookie_name()] ?? '';
227 if (!is_string($cookie) || $cookie === '') {
228 return false;
229 }
230
231 $hash = hash('sha256', $cookie);
232 $row = db()->fetchOne("
233 SELECT `id` FROM `znote_2fa_trusted_devices`
234 WHERE `account_id` = ? AND `token_hash` = ? AND `expires_at` > ?
235 LIMIT 1;
236 ", [$accountId, $hash, time()]);
237
238 if (!is_array($row)) {
239 return false;
240 }
241
242 db()->execute("UPDATE `znote_2fa_trusted_devices` SET `last_used_at` = ? WHERE `id` = ?;", [time(), (int)$row['id']]);
243 return true;
244}
245
246function znote2fa_trusted_device_issue(int $accountId): void {
247 $days = znote2fa_v2_config()['trusted_device_days'];
248 if ($days <= 0) {
249 return;
250 }
251
252 global $config;
253 $token = bin2hex(random_bytes(32));
254 $hash = hash('sha256', $token);
255 $expires = time() + ($days * 86400);
256 $label = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? 'Unknown device'), 0, 255);
257
258 db()->execute("
259 INSERT INTO `znote_2fa_trusted_devices` (`account_id`, `token_hash`, `label`, `ip`, `created_at`, `expires_at`, `last_used_at`)
260 VALUES (?, ?, ?, ?, ?, ?, ?);
261 ", [$accountId, $hash, $label, (string)($_SERVER['REMOTE_ADDR'] ?? ''), time(), $expires, time()]);
262
263 $secure = (bool)znote_session_request_is_https();
264 setcookie(znote2fa_trusted_cookie_name(), $token, array(
265 'expires' => $expires,
266 'path' => (string)($config['session']['cookie_path'] ?? '/'),
267 'domain' => (string)($config['session']['cookie_domain'] ?? ''),
268 'secure' => $secure,
269 'httponly' => true,
270 'samesite' => (string)($config['session']['cookie_samesite'] ?? 'Lax'),
271 ));
272}
273
274function znote2fa_trusted_devices_list(int $accountId): array {
275 $rows = db()->fetchAll("
276 SELECT `id`, `label`, `ip`, `created_at`, `expires_at`, `last_used_at`
277 FROM `znote_2fa_trusted_devices`
278 WHERE `account_id` = ?
279 ORDER BY `last_used_at` DESC;
280 ", [$accountId]);
281
282 return is_array($rows) ? $rows : array();
283}
284
285function znote2fa_trusted_device_revoke(int $accountId, int $deviceId): void {
286 db()->execute("DELETE FROM `znote_2fa_trusted_devices` WHERE `id` = ? AND `account_id` = ?;", [$deviceId, $accountId]);
287}
288
289function znote2fa_login_challenge_required(bool $siteEnabled, array $status): bool {
290 return $siteEnabled && !empty($status['any_enabled']);
291}
292
293function znote2fa_required(int $accountId, bool $isAdmin = false): bool {
294 if (!znote2fa_v2_enabled()) {
295 return false;
296 }
297
298 return znote2fa_login_challenge_required(true, znote2fa_status($accountId));
299}
300
301function znote2fa_setup_incomplete(int $accountId, bool $isAdmin): bool {
302 return znote2fa_v2_enabled() && $isAdmin && znote2fa_v2_config()['force_admins'] && !znote2fa_status($accountId)['any_enabled'];
303}
304
305function znote2fa_verify_login_input(int $accountId, string $input): bool {
306 $input = trim($input);
307 if ($input === '') {
308 return false;
309 }
310
311 if (preg_match('/^\d{6}$/', $input)) {
312 if (znote2fa_totp_verify($accountId, $input)) {
313 return true;
314 }
315 if (znote2fa_email_verify_code($accountId, $input)) {
316 return true;
317 }
318 return false;
319 }
320
321 return znote2fa_recovery_consume($accountId, $input);
322}
323