Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
TwoFactor
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
6 / 6
12
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 generateSecret
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveSecret
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getSecret
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 validateToken
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 getQrCode
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Class for Two-Factor Authentication (2FA).
5 *
6 * This class handles all operations around creating, saving and getting the secret
7 * for a CurrentUser for two-factor-authentication. It also validates given tokens in
8 * comparison to a given secret and returns a QR-code for transmitting a secret to
9 * the authenticator-app.
10 *
11 * This Source Code Form is subject to the terms of the Mozilla Public License,
12 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
13 * obtain one at http://mozilla.org/MPL/2.0/.
14 *
15 * @package   phpMyFAQ
16 * @author    Jan Harms <model_railroader@gmx-topmail.de>
17 * @copyright 2023-2026 phpMyFAQ Team
18 * @license   http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
19 * @link      https://www.phpmyfaq.de
20 * @since     2023-03-11
21 */
22
23declare(strict_types=1);
24
25namespace phpMyFAQ\User;
26
27use phpMyFAQ\Configuration;
28use RobThree\Auth\Algorithm;
29use RobThree\Auth\Providers\Qr\EndroidQrCodeProvider;
30use RobThree\Auth\TwoFactorAuth;
31use RobThree\Auth\TwoFactorAuthException;
32
33class TwoFactor
34{
35    private TwoFactorAuth $twoFactorAuth;
36
37    private EndroidQrCodeProvider $endroidQrCodeProvider;
38
39    /**
40     * Number of adjacent time slices accepted besides the current one.
41     *
42     * RobThree defaults to 1, which keeps a code usable for roughly 90 seconds and
43     * leaves a captured code replayable for that whole span. Accepting only the
44     * current slice shortens that to at most 30 seconds. The trade-off is that
45     * authenticator apps whose clock has drifted by more than one period are no
46     * longer tolerated.
47     */
48    private const int VERIFY_DISCREPANCY = 0;
49
50    /**
51     * @throws TwoFactorAuthException
52     */
53    public function __construct(
54        private readonly Configuration $configuration,
55        private readonly CurrentUser $currentUser,
56    ) {
57        $this->endroidQrCodeProvider = new EndroidQrCodeProvider();
58        $this->twoFactorAuth = new TwoFactorAuth(
59            $this->endroidQrCodeProvider,
60            (string) $this->configuration->get(item: 'main.titleFAQ'),
61            6,
62            30,
63            Algorithm::Sha1,
64        );
65    }
66
67    /**
68     * Generates and returns a new secret without saving
69     */
70    public function generateSecret(): string
71    {
72        return $this->twoFactorAuth->createSecret();
73    }
74
75    /**
76     * Saves a given secret to the current user from the session.
77     */
78    public function saveSecret(#[\SensitiveParameter] string $secret): bool
79    {
80        if ($secret === '') {
81            return false;
82        }
83
84        return $this->currentUser->setUserData(['secret' => $secret]);
85    }
86
87    /**
88     * Returns the secret of the current user
89     */
90    public function getSecret(CurrentUser $currentUser): ?string
91    {
92        $secret = $currentUser->getUserData('secret');
93
94        return is_string($secret) ? $secret : null;
95    }
96
97    /**
98     * Validates a given token. Returns true if the token is correct.
99     */
100    public function validateToken(#[\SensitiveParameter] string $token, int $userId): bool
101    {
102        if (strlen($token) !== 6 || $userId <= 0) {
103            return false;
104        }
105
106        $this->currentUser->getUserById($userId);
107
108        $secret = $this->currentUser->getUserData('secret');
109        if (!is_string($secret) || $secret === '') {
110            return false;
111        }
112
113        return $this->twoFactorAuth->verifyCode($secret, $token, self::VERIFY_DISCREPANCY);
114    }
115
116    /**
117     * Returns a QR-Code to a given secret for transmitting the secret to the Authenticator-App
118     */
119    public function getQrCode(#[\SensitiveParameter] string $secret): string
120    {
121        $label = $this->configuration->getTitle() . ':' . (string) $this->currentUser->getUserData('email');
122        $qrCodeText = sprintf(
123            '%s&image=%sassets/templates/images/logo.png',
124            $this->twoFactorAuth->getQrText($label, $secret),
125            $this->configuration->getDefaultUrl(),
126        );
127
128        return sprintf(
129            'data:%s;base64,%s',
130            $this->endroidQrCodeProvider->getMimeType(),
131            base64_encode($this->endroidQrCodeProvider->getQRCodeImage($qrCodeText, 200)),
132        );
133    }
134}