Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
39.19% |
29 / 74 |
|
63.64% |
7 / 11 |
CRAP | |
0.00% |
0 / 1 |
TOTPKey | |
39.19% |
29 / 74 |
|
63.64% |
7 / 11 |
120.17 | |
0.00% |
0 / 1 |
newFromRandom | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
newFromArray | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 | |||
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getId | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getSecret | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getScratchTokens | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
verify | |
0.00% |
0 / 42 |
|
0.00% |
0 / 1 |
72 | |||
regenerateScratchTokens | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
isScratchToken | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
jsonSerialize | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace 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 | |
22 | use Base32\Base32; |
23 | use DomainException; |
24 | use EmptyBagOStuff; |
25 | use Exception; |
26 | use jakobo\HOTP\HOTP; |
27 | use MediaWiki\Extension\OATHAuth\IAuthKey; |
28 | use MediaWiki\Extension\OATHAuth\OATHAuthServices; |
29 | use MediaWiki\Extension\OATHAuth\OATHUser; |
30 | use MediaWiki\Logger\LoggerFactory; |
31 | use MediaWiki\MediaWikiServices; |
32 | use MWException; |
33 | use ObjectCache; |
34 | use 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 | */ |
43 | class 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 | } |