Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
39.19% covered (danger)
39.19%
29 / 74
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
TOTPKey
39.19% covered (danger)
39.19%
29 / 74
63.64% covered (warning)
63.64%
7 / 11
120.17
0.00% covered (danger)
0.00%
0 / 1
 newFromRandom
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 newFromArray
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSecret
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getScratchTokens
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 verify
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
72
 regenerateScratchTokens
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isScratchToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 jsonSerialize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\OATHAuth\Key;
4
5/**
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with this program; if not, write to the Free Software Foundation, Inc.,
18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * http://www.gnu.org/copyleft/gpl.html
20 */
21
22use Base32\Base32;
23use DomainException;
24use EmptyBagOStuff;
25use Exception;
26use jakobo\HOTP\HOTP;
27use MediaWiki\Extension\OATHAuth\IAuthKey;
28use MediaWiki\Extension\OATHAuth\OATHAuthServices;
29use MediaWiki\Extension\OATHAuth\OATHUser;
30use MediaWiki\Logger\LoggerFactory;
31use MediaWiki\MediaWikiServices;
32use MWException;
33use ObjectCache;
34use Psr\Log\LoggerInterface;
35
36/**
37 * Class representing a two-factor key
38 *
39 * Keys can be tied to OATHUsers
40 *
41 * @ingroup Extensions
42 */
43class TOTPKey implements IAuthKey {
44    /** @var int|null */
45    private ?int $id;
46
47    /** @var array Two factor binary secret */
48    private $secret;
49
50    /** @var string[] List of recovery codes */
51    private $recoveryCodes = [];
52
53    /**
54     * @return TOTPKey
55     * @throws Exception
56     */
57    public static function newFromRandom() {
58        $object = new self(
59            null,
60            Base32::encode( random_bytes( 10 ) ),
61            []
62        );
63
64        $object->regenerateScratchTokens();
65
66        return $object;
67    }
68
69    /**
70     * @param array $data
71     * @return TOTPKey|null on invalid data
72     */
73    public static function newFromArray( array $data ) {
74        if ( !isset( $data['secret'] ) || !isset( $data['scratch_tokens'] ) ) {
75            return null;
76        }
77        return new static( $data['id'] ?? null, $data['secret'], $data['scratch_tokens'] );
78    }
79
80    /**
81     * @param int|null $id the database id of this key
82     * @param string $secret
83     * @param array $recoveryCodes
84     */
85    public function __construct( ?int $id, $secret, array $recoveryCodes ) {
86        $this->id = $id;
87
88        // Currently hardcoded values; might be used in the future
89        $this->secret = [
90            'mode' => 'hotp',
91            'secret' => $secret,
92            'period' => 30,
93            'algorithm' => 'SHA1',
94        ];
95        $this->recoveryCodes = array_values( $recoveryCodes );
96    }
97
98    /**
99     * @return int|null
100     */
101    public function getId(): ?int {
102        return $this->id;
103    }
104
105    /**
106     * @return string
107     */
108    public function getSecret() {
109        return $this->secret['secret'];
110    }
111
112    /**
113     * @return string[]
114     */
115    public function getScratchTokens() {
116        return $this->recoveryCodes;
117    }
118
119    /**
120     * @param array $data
121     * @param OATHUser $user
122     * @return bool
123     * @throws MWException
124     */
125    public function verify( $data, OATHUser $user ) {
126        global $wgOATHAuthWindowRadius;
127
128        $token = $data['token'];
129
130        if ( $this->secret['mode'] !== 'hotp' ) {
131            throw new DomainException( 'OATHAuth extension does not support non-HOTP tokens' );
132        }
133
134        // Prevent replay attacks
135        $store = MediaWikiServices::getInstance()->getMainObjectStash();
136
137        if ( $store instanceof EmptyBagOStuff ) {
138            // Try and find some usable cache if the MainObjectStash isn't useful
139            $store = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
140        }
141
142        $key = $store->makeKey( 'oathauth-totp', 'usedtokens', $user->getCentralId() );
143        $lastWindow = (int)$store->get( $key );
144
145        $results = HOTP::generateByTimeWindow(
146            Base32::decode( $this->secret['secret'] ),
147            $this->secret['period'],
148            -$wgOATHAuthWindowRadius,
149            $wgOATHAuthWindowRadius
150        );
151
152        // Remove any whitespace from the received token, which can be an intended group separator
153        $token = preg_replace( '/\s+/', '', $token );
154
155        $clientIP = $user->getUser()->getRequest()->getIP();
156
157        $logger = $this->getLogger();
158
159        // Check to see if the user's given token is in the list of tokens generated
160        // for the time window.
161        foreach ( $results as $window => $result ) {
162            if ( $window > $lastWindow && hash_equals( $result->toHOTP( 6 ), $token ) ) {
163                $lastWindow = $window;
164
165                $logger->info( 'OATHAuth user {user} entered a valid OTP from {clientip}', [
166                    'user' => $user->getAccount(),
167                    'clientip' => $clientIP,
168                ] );
169
170                $store->set(
171                    $key,
172                    $lastWindow,
173                    $this->secret['period'] * ( 1 + 2 * $wgOATHAuthWindowRadius )
174                );
175
176                return true;
177            }
178        }
179
180        // See if the user is using a recovery code
181        foreach ( $this->recoveryCodes as $i => $recoveryCode ) {
182            if ( hash_equals( $token, $recoveryCode ) ) {
183                // If we used a recovery code, remove it from the recovery code list.
184                // This is saved below via OATHUserRepository::persist
185                array_splice( $this->recoveryCodes, $i, 1 );
186
187                $logger->info( 'OATHAuth user {user} used a recovery token from {clientip}', [
188                    'user' => $user->getAccount(),
189                    'clientip' => $clientIP,
190                ] );
191
192                $userRepo = OATHAuthServices::getInstance()->getUserRepository();
193                // TODO: support for multiple keys
194                $user->setKeys( [ $this ] );
195                $userRepo->persist( $user, $clientIP );
196
197                return true;
198            }
199        }
200
201        return false;
202    }
203
204    public function regenerateScratchTokens() {
205        $scratchTokens = [];
206        for ( $i = 0; $i < 10; $i++ ) {
207            $scratchTokens[] = Base32::encode( random_bytes( 10 ) );
208        }
209        $this->recoveryCodes = $scratchTokens;
210    }
211
212    /**
213     * Check if a token is one of the recovery codes for this two-factor key.
214     *
215     * @param string $token Token to verify
216     *
217     * @return bool true if this is a recovery code.
218     */
219    public function isScratchToken( $token ) {
220        $token = preg_replace( '/\s+/', '', $token );
221        return in_array( $token, $this->recoveryCodes, true );
222    }
223
224    /**
225     * @return LoggerInterface
226     */
227    private function getLogger() {
228        return LoggerFactory::getInstance( 'authentication' );
229    }
230
231    public function jsonSerialize(): array {
232        return [
233            'secret' => $this->getSecret(),
234            'scratch_tokens' => $this->getScratchTokens()
235        ];
236    }
237}