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
;
20 * Implementation of random backup codes.
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
28 class BackupMultifactorMethod
implements IMultifactorMethod
{
30 * @var IPasswordAlgorithm
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
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
47 public const CHUNKS
= 4;
48 public const CHUNK_LENGTH
= 5;
51 * The number of codes to generate.
53 private const CODE_COUNT
= 10;
55 private const USER_ATTEMPTS_PER_HOUR
= 5;
57 public function __construct() {
58 $this->algorithm
= new Bcrypt();
62 * Returns the number of remaining codes.
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
68 $statement = WCF
::getDB()->prepareStatement($sql);
69 $statement->execute([$setup->getId()]);
71 return WCF
::getLanguage()->getDynamicVariable(
72 'wcf.user.security.multifactor.backup.status',
73 $statement->fetchArray()
81 public function createManagementForm(IFormDocument
$form, ?Setup
$setup, $returnData = null): void
{
82 $form->addDefaultButton(false);
83 $form->successMessage('wcf.user.security.multifactor.backup.success');
87 FROM wcf".WCF_N
."_user_multifactor_backup
89 $statement = WCF
::getDB()->prepareStatement($sql);
90 $statement->execute([$setup->getId()]);
92 $codes = $statement->fetchAll(\PDO
::FETCH_ASSOC
);
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
);
103 while (\
count($code['chunks']) < self
::CHUNKS
) {
104 $code['chunks'][] = \
str_repeat("\xE2\x80\xA2", self
::CHUNK_LENGTH
);
111 $statusContainer = FormContainer
::create('existingCodesContainer')
112 ->label('wcf.user.security.multifactor.backup.existingCodes')
114 TemplateFormNode
::create('existingCodes')
115 ->templateName('multifactorManageBackup')
118 'isUnveiled' => $returnData !== null,
121 $form->appendChild($statusContainer);
123 $regenerateContainer = FormContainer
::create('regenerateCodesContainer')
124 ->label('wcf.user.security.multifactor.backup.regenerateCodes')
126 CustomFormNode
::create('explanation')
127 ->content(WCF
::getLanguage()->getDynamicVariable(
128 'wcf.user.security.multifactor.backup.regenerateCodes.description'
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'));
140 $form->appendChild($regenerateContainer);
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')
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'));
158 $form->appendChild($generateContainer);
163 * Generates a list of codes.
165 private function generateCodes(): array {
167 for ($i = 0; $i < self
::CODE_COUNT
; $i++
) {
169 for ($part = 0; $part < self
::CHUNKS
; $part++
) {
170 $chunks[] = \random_int
(
171 10 ** (self
::CHUNK_LENGTH
- 1),
172 (10 ** self
::CHUNK_LENGTH
) - 1
176 $identifier = $chunks[0];
177 if (isset($codes[$identifier])) {
181 $codes[$identifier] = \
implode('', $chunks);
190 public function processManagementForm(IFormDocument
$form, Setup
$setup): array {
191 $formData = $form->getData();
192 \assert
($formData['action'] === 'generateCodes' ||
$formData['action'] === 'regenerateCodes');
194 $sql = "DELETE FROM wcf".WCF_N
."_user_multifactor_backup
196 $statement = WCF
::getDB()->prepareStatement($sql);
197 $statement->execute([$setup->getId()]);
199 $codes = $this->generateCodes();
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([
210 $algorithmName.':'.$this->algorithm
->hash($code),
219 * Returns a code from $codes matching the $userCode. `null` is returned if
220 * no matching code could be found.
222 private function findValidCode(string $userCode, array $codes): ?
array {
223 $manager = PasswordAlgorithmManager
::getInstance();
226 foreach ($codes as $code) {
227 [$algorithmName, $hash] = \
explode(':', $code['code'], 2);
228 $algorithm = $manager->getAlgorithmFromName($algorithmName);
230 // The use of `&` is intentional to disable the shortcutting logic.
231 if ($algorithm->verify($userCode, $hash) & $code['useTime'] === null) {
242 public function createAuthenticationForm(IFormDocument
$form, Setup
$setup): void
{
244 FROM wcf".WCF_N
."_user_multifactor_backup
246 $statement = WCF
::getDB()->prepareStatement($sql);
247 $statement->execute([$setup->getId()]);
248 $codes = $statement->fetchAll(\PDO
::FETCH_ASSOC
);
250 $form->appendChildren([
251 CodeFormField
::create()
252 ->label('wcf.user.security.multifactor.backup.code')
253 ->description('wcf.user.security.multifactor.backup.code.description')
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
) {
261 $field->addValidationError(new FormFieldValidationError(
263 'wcf.user.security.multifactor.backup.error.flood',
269 $userCode = \
preg_replace('/\s+/', '', $field->getValue());
271 if ($this->findValidCode($userCode, $codes) === null) {
273 $field->addValidationError(new FormFieldValidationError('invalid'));
282 public function processAuthenticationForm(IFormDocument
$form, Setup
$setup): void
{
283 $userCode = \
preg_replace('/\s+/', '', $form->getData()['data']['code']);
286 FROM wcf".WCF_N
."_user_multifactor_backup
289 $statement = WCF
::getDB()->prepareStatement($sql);
290 $statement->execute([$setup->getId()]);
291 $codes = $statement->fetchAll(\PDO
::FETCH_ASSOC
);
293 $usedCode = $this->findValidCode($userCode, $codes);
295 if ($usedCode === null) {
296 throw new \
RuntimeException('Unable to find a valid code.');
299 $sql = "UPDATE wcf".WCF_N
."_user_multifactor_backup
303 AND useTime IS NULL";
304 $statement = WCF
::getDB()->prepareStatement($sql);
305 $statement->execute([
308 $usedCode['identifier'],
311 if ($statement->getAffectedRows() !== 1) {
312 throw new \
RuntimeException('Unable to invalidate the code.');
315 $this->sendAuthenticationEmail($setup, $usedCode);
319 * Notifies the user that an emergency code has been used.
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
325 $statement = WCF
::getDB()->prepareStatement($sql);
326 $statement->execute([$setup->getId()]);
328 $remaining = $statement->fetchSingleColumn();
330 $email = new SimpleEmail();
331 $email->setRecipient($setup->getUser());
334 WCF
::getLanguage()->getDynamicVariable('wcf.user.security.multifactor.backup.authenticationEmail.subject', [
335 'remaining' => $remaining,
336 'usedCode' => $usedCode,
340 $email->setHtmlMessage(
341 WCF
::getLanguage()->getDynamicVariable('wcf.user.security.multifactor.backup.authenticationEmail.body.html', [
342 'remaining' => $remaining,
343 'usedCode' => $usedCode,
348 WCF
::getLanguage()->getDynamicVariable('wcf.user.security.multifactor.backup.authenticationEmail.body.plain', [
349 'remaining' => $remaining,
350 'usedCode' => $usedCode,