Fix validation of hashes in BackupMultifactorMethod
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / user / multifactor / BackupMultifactorMethod.class.php
1 <?php
2 namespace wcf\system\user\multifactor;
3 use wcf\system\email\SimpleEmail;
4 use wcf\system\flood\FloodControl;
5 use wcf\system\form\builder\container\FormContainer;
6 use wcf\system\form\builder\CustomFormNode;
7 use wcf\system\form\builder\field\ButtonFormField;
8 use wcf\system\form\builder\field\TextFormField;
9 use wcf\system\form\builder\field\validation\FormFieldValidationError;
10 use wcf\system\form\builder\field\validation\FormFieldValidator;
11 use wcf\system\form\builder\IFormDocument;
12 use wcf\system\form\builder\TemplateFormNode;
13 use wcf\system\user\authentication\password\algorithm\Bcrypt;
14 use wcf\system\user\authentication\password\IPasswordAlgorithm;
15 use wcf\system\user\authentication\password\PasswordAlgorithmManager;
16 use wcf\system\user\multifactor\backup\CodeFormField;
17 use wcf\system\WCF;
18
19 /**
20 * Implementation of random backup codes.
21 *
22 * @author Tim Duesterhus
23 * @copyright 2001-2020 WoltLab GmbH
24 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
25 * @package WoltLabSuite\System\User\Multifactor
26 * @since 5.4
27 */
28 class BackupMultifactorMethod implements IMultifactorMethod {
29 /**
30 * @var IPasswordAlgorithm
31 */
32 private $algorithm;
33
34 // 4 chunks of 5 digits each result in code space of 10^20 which
35 // is equivalent to 66.4 bits of security. The unhashed 3 chunks
36 // of 5 digits result in 10^15 which is equivalent to 49.8 bits
37 // of security.
38 // This is sufficient for a rate-limited online attack, but a bit
39 // short for an offline attack using a stolen database. In the
40 // latter case the TOTP secret which needs to be stored in a form
41 // that allows generating valid codes poses a far bigger threat
42 // to a single user's security.
43 // Thus we use a 20 digit code. It gives users a warm and fuzzy
44 // feeling that the codes cannot be easily guessed (due to being
45 // longish), while not being unwieldy like a hexadecimal, base32
46 // or base64 string.
47 public const CHUNKS = 4;
48 public const CHUNK_LENGTH = 5;
49
50 /**
51 * The number of codes to generate.
52 */
53 private const CODE_COUNT = 10;
54
55 private const USER_ATTEMPTS_PER_HOUR = 5;
56
57 public function __construct() {
58 $this->algorithm = new Bcrypt();
59 }
60
61 /**
62 * Returns the number of remaining codes.
63 */
64 public function getStatusText(Setup $setup): string {
65 $sql = "SELECT COUNT(*) - COUNT(useTime) AS count, MAX(useTime) AS lastUsed
66 FROM wcf".WCF_N."_user_multifactor_backup
67 WHERE setupID = ?";
68 $statement = WCF::getDB()->prepareStatement($sql);
69 $statement->execute([$setup->getId()]);
70
71 return WCF::getLanguage()->getDynamicVariable(
72 'wcf.user.security.multifactor.backup.status',
73 $statement->fetchArray()
74 );
75
76 }
77
78 /**
79 * @inheritDoc
80 */
81 public function createManagementForm(IFormDocument $form, ?Setup $setup, $returnData = null): void {
82 $form->addDefaultButton(false);
83 $form->successMessage('wcf.user.security.multifactor.backup.success');
84
85 if ($setup) {
86 $sql = "SELECT *
87 FROM wcf".WCF_N."_user_multifactor_backup
88 WHERE setupID = ?";
89 $statement = WCF::getDB()->prepareStatement($sql);
90 $statement->execute([$setup->getId()]);
91
92 $codes = $statement->fetchAll(\PDO::FETCH_ASSOC);
93
94 $codes = array_map(function ($code) use ($returnData) {
95 if (isset($returnData[$code['identifier']])) {
96 $code['chunks'] = \str_split($returnData[$code['identifier']], self::CHUNK_LENGTH);
97 }
98 else {
99 $code['chunks'] = [
100 $code['identifier'],
101 ];
102
103 while (\count($code['chunks']) < self::CHUNKS) {
104 $code['chunks'][] = \str_repeat("\xE2\x80\xA2", self::CHUNK_LENGTH);
105 }
106 }
107
108 return $code;
109 }, $codes);
110
111 $statusContainer = FormContainer::create('existingCodesContainer')
112 ->label('wcf.user.security.multifactor.backup.existingCodes')
113 ->appendChildren([
114 TemplateFormNode::create('existingCodes')
115 ->templateName('multifactorManageBackup')
116 ->variables([
117 'codes' => $codes,
118 'isUnveiled' => $returnData !== null,
119 ]),
120 ]);
121 $form->appendChild($statusContainer);
122
123 $regenerateContainer = FormContainer::create('regenerateCodesContainer')
124 ->label('wcf.user.security.multifactor.backup.regenerateCodes')
125 ->appendChildren([
126 CustomFormNode::create('explanation')
127 ->content(WCF::getLanguage()->getDynamicVariable(
128 'wcf.user.security.multifactor.backup.regenerateCodes.description'
129 )),
130 ButtonFormField::create('regenerateCodes')
131 ->buttonLabel('wcf.user.security.multifactor.backup.regenerateCodes')
132 ->objectProperty('action')
133 ->value('regenerateCodes')
134 ->addValidator(new FormFieldValidator('regenerateCodes', function (ButtonFormField $field) {
135 if ($field->getValue() === null) {
136 $field->addValidationError(new FormFieldValidationError('unreachable', 'unreachable'));
137 }
138 })),
139 ]);
140 $form->appendChild($regenerateContainer);
141 }
142 else {
143 // This part of the form is not visible to the end user. It will be implicitly filled in
144 // when setting up the first multi-factor method.
145 $generateContainer = FormContainer::create('generateCodesContainer')
146 ->label('wcf.user.security.multifactor.backup.generateCodes')
147 ->appendChildren([
148 ButtonFormField::create('generateCodes')
149 ->buttonLabel('wcf.user.security.multifactor.backup.generateCodes')
150 ->objectProperty('action')
151 ->value('generateCodes')
152 ->addValidator(new FormFieldValidator('generateCodes', function (ButtonFormField $field) {
153 if ($field->getValue() === null) {
154 $field->addValidationError(new FormFieldValidationError('unreachable', 'unreachable'));
155 }
156 })),
157 ]);
158 $form->appendChild($generateContainer);
159 }
160 }
161
162 /**
163 * Generates a list of codes.
164 */
165 private function generateCodes(): array {
166 $codes = [];
167 for ($i = 0; $i < self::CODE_COUNT; $i++) {
168 $chunks = [];
169 for ($part = 0; $part < self::CHUNKS; $part++) {
170 $chunks[] = \random_int(
171 10 ** (self::CHUNK_LENGTH - 1),
172 (10 ** self::CHUNK_LENGTH) - 1
173 );
174 }
175
176 $identifier = $chunks[0];
177 if (isset($codes[$identifier])) {
178 continue;
179 }
180
181 $codes[$identifier] = \implode('', $chunks);
182 }
183
184 return $codes;
185 }
186
187 /**
188 * @inheritDoc
189 */
190 public function processManagementForm(IFormDocument $form, Setup $setup): array {
191 $formData = $form->getData();
192 \assert($formData['action'] === 'generateCodes' || $formData['action'] === 'regenerateCodes');
193
194 $sql = "DELETE FROM wcf".WCF_N."_user_multifactor_backup
195 WHERE setupID = ?";
196 $statement = WCF::getDB()->prepareStatement($sql);
197 $statement->execute([$setup->getId()]);
198
199 $codes = $this->generateCodes();
200
201 $sql = "INSERT INTO wcf".WCF_N."_user_multifactor_backup
202 (setupID, identifier, code, createTime)
203 VALUES (?, ?, ?, ?)";
204 $statement = WCF::getDB()->prepareStatement($sql);
205 $algorithmName = PasswordAlgorithmManager::getInstance()->getNameFromAlgorithm($this->algorithm);
206 foreach ($codes as $identifier => $code) {
207 $statement->execute([
208 $setup->getId(),
209 $identifier,
210 $algorithmName.':'.$this->algorithm->hash($code),
211 \TIME_NOW,
212 ]);
213 }
214
215 return $codes;
216 }
217
218 /**
219 * Returns a code from $codes matching the $userCode. `null` is returned if
220 * no matching code could be found.
221 */
222 private function findValidCode(string $userCode, array $codes): ?array {
223 $manager = PasswordAlgorithmManager::getInstance();
224
225 $result = null;
226 foreach ($codes as $code) {
227 [$algorithmName, $hash] = \explode(':', $code['code'], 2);
228 $algorithm = $manager->getAlgorithmFromName($algorithmName);
229
230 // The use of `&` is intentional to disable the shortcutting logic.
231 if ($algorithm->verify($userCode, $hash) & $code['useTime'] === null) {
232 $result = $code;
233 }
234 }
235
236 return $result;
237 }
238
239 /**
240 * @inheritDoc
241 */
242 public function createAuthenticationForm(IFormDocument $form, Setup $setup): void {
243 $sql = "SELECT *
244 FROM wcf".WCF_N."_user_multifactor_backup
245 WHERE setupID = ?";
246 $statement = WCF::getDB()->prepareStatement($sql);
247 $statement->execute([$setup->getId()]);
248 $codes = $statement->fetchAll(\PDO::FETCH_ASSOC);
249
250 $form->appendChildren([
251 CodeFormField::create()
252 ->label('wcf.user.security.multifactor.backup.code')
253 ->description('wcf.user.security.multifactor.backup.code.description')
254 ->autoFocus()
255 ->required()
256 ->addValidator(new FormFieldValidator('code', function (TextFormField $field) use ($codes, $setup) {
257 FloodControl::getInstance()->registerUserContent('com.woltlab.wcf.multifactor.backup', $setup->getId());
258 $attempts = FloodControl::getInstance()->countUserContent('com.woltlab.wcf.multifactor.backup', $setup->getId(), new \DateInterval('PT1H'));
259 if ($attempts['count'] > self::USER_ATTEMPTS_PER_HOUR) {
260 $field->value('');
261 $field->addValidationError(new FormFieldValidationError(
262 'flood',
263 'wcf.user.security.multifactor.backup.error.flood',
264 $attempts
265 ));
266 return;
267 }
268
269 $userCode = \preg_replace('/\s+/', '', $field->getValue());
270
271 if ($this->findValidCode($userCode, $codes) === null) {
272 $field->value('');
273 $field->addValidationError(new FormFieldValidationError('invalid'));
274 }
275 })),
276 ]);
277 }
278
279 /**
280 * @inheritDoc
281 */
282 public function processAuthenticationForm(IFormDocument $form, Setup $setup): void {
283 $userCode = \preg_replace('/\s+/', '', $form->getData()['data']['code']);
284
285 $sql = "SELECT *
286 FROM wcf".WCF_N."_user_multifactor_backup
287 WHERE setupID = ?
288 FOR UPDATE";
289 $statement = WCF::getDB()->prepareStatement($sql);
290 $statement->execute([$setup->getId()]);
291 $codes = $statement->fetchAll(\PDO::FETCH_ASSOC);
292
293 $usedCode = $this->findValidCode($userCode, $codes);
294
295 if ($usedCode === null) {
296 throw new \RuntimeException('Unable to find a valid code.');
297 }
298
299 $sql = "UPDATE wcf".WCF_N."_user_multifactor_backup
300 SET useTime = ?
301 WHERE setupID = ?
302 AND identifier = ?
303 AND useTime IS NULL";
304 $statement = WCF::getDB()->prepareStatement($sql);
305 $statement->execute([
306 \TIME_NOW,
307 $setup->getId(),
308 $usedCode['identifier'],
309 ]);
310
311 if ($statement->getAffectedRows() !== 1) {
312 throw new \RuntimeException('Unable to invalidate the code.');
313 }
314
315 $this->sendAuthenticationEmail($setup, $usedCode);
316 }
317
318 /**
319 * Notifies the user that an emergency code has been used.
320 */
321 private function sendAuthenticationEmail(Setup $setup, array $usedCode): void {
322 $sql = "SELECT COUNT(*) - COUNT(useTime) AS count
323 FROM wcf".WCF_N."_user_multifactor_backup
324 WHERE setupID = ?";
325 $statement = WCF::getDB()->prepareStatement($sql);
326 $statement->execute([$setup->getId()]);
327
328 $remaining = $statement->fetchSingleColumn();
329
330 $email = new SimpleEmail();
331 $email->setRecipient($setup->getUser());
332
333 $email->setSubject(
334 WCF::getLanguage()->getDynamicVariable('wcf.user.security.multifactor.backup.authenticationEmail.subject', [
335 'remaining' => $remaining,
336 'usedCode' => $usedCode,
337 'setup' => $setup,
338 ])
339 );
340 $email->setHtmlMessage(
341 WCF::getLanguage()->getDynamicVariable('wcf.user.security.multifactor.backup.authenticationEmail.body.html', [
342 'remaining' => $remaining,
343 'usedCode' => $usedCode,
344 'setup' => $setup,
345 ])
346 );
347 $email->setMessage(
348 WCF::getLanguage()->getDynamicVariable('wcf.user.security.multifactor.backup.authenticationEmail.body.plain', [
349 'remaining' => $remaining,
350 'usedCode' => $usedCode,
351 'setup' => $setup,
352 ])
353 );
354
355 $email->send();
356 }
357 }