Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.50% covered (warning)
87.50%
77 / 88
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
TOTPKey
87.50% covered (warning)
87.50%
77 / 88
91.67% covered (success)
91.67%
11 / 12
24.03
0.00% covered (danger)
0.00%
0 / 1
 newFromRandom
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 removeBase32Padding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newFromArray
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getSecret
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setEncryptedSecretAndNonce
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getEncryptedSecretAndNonce
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 verify
65.62% covered (warning)
65.62%
21 / 32
0.00% covered (danger)
0.00%
0 / 1
7.46
 getModule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 jsonSerialize
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 getEncryptionHelper
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * @license GPL-2.0-or-later
5 */
6
7namespace MediaWiki\Extension\OATHAuth\Key;
8
9use Base32\Base32;
10use DomainException;
11use jakobo\HOTP\HOTP;
12use MediaWiki\Context\RequestContext;
13use MediaWiki\Extension\OATHAuth\Module\TOTP;
14use MediaWiki\Extension\OATHAuth\OATHAuthServices;
15use MediaWiki\Extension\OATHAuth\OATHUser;
16use MediaWiki\Logger\LoggerFactory;
17use MediaWiki\MediaWikiServices;
18use Psr\Log\LoggerInterface;
19use UnexpectedValueException;
20use Wikimedia\ObjectCache\EmptyBagOStuff;
21use Wikimedia\Timestamp\ConvertibleTimestamp;
22use Wikimedia\Timestamp\TimestampFormat;
23
24/**
25 * Class representing a two-factor key
26 *
27 * Keys can be tied to OATHUsers
28 *
29 * @ingroup Extensions
30 */
31class TOTPKey extends AuthKey {
32    /** TOTP binary secret */
33    private array $secret;
34
35    public static function newFromRandom(): TOTPKey {
36        return new self(
37            null,
38            null,
39            null,
40            // 26 digits to give 128 bits - https://phabricator.wikimedia.org/T396951
41            self::removeBase32Padding( Base32::encode( random_bytes( 26 ) ) ),
42        );
43    }
44
45    /**
46     * @param string $paddedBase32String
47     * @return string
48     * @see T408225, T401393
49     */
50    public static function removeBase32Padding( string $paddedBase32String ) {
51        return rtrim( $paddedBase32String, '=' );
52    }
53
54    /**
55     * @param array $data
56     * @return TOTPKey|null on invalid data
57     * @throws UnexpectedValueException When encryption is not configured but db is encrypted
58     */
59    public static function newFromArray( array $data ) {
60        if ( !isset( $data['secret'] ) ) {
61            return null;
62        }
63
64        if ( isset( $data['nonce'] ) ) {
65            $encryptionHelper = self::getEncryptionHelper();
66            if ( !$encryptionHelper->isEnabled() ) {
67                // @codeCoverageIgnoreStart
68                throw new UnexpectedValueException(
69                    'Encryption is not configured but OATHAuth is attempting to use encryption'
70                );
71                // @codeCoverageIgnoreEnd
72            }
73            $data['encrypted_secret'] = $data['secret'];
74            $data['secret'] = $encryptionHelper->decrypt( $data['secret'], $data['nonce'] );
75        } else {
76            $data['encrypted_secret'] = '';
77            $data['nonce'] = '';
78        }
79
80        return new static(
81            $data['id'] ?? null,
82            $data['friendly_name'] ?? null,
83            $data['created_timestamp'] ?? null,
84            $data['secret'] ?? '',
85            $data['encrypted_secret'],
86            $data['nonce']
87        );
88    }
89
90    public function __construct(
91        ?int $id,
92        ?string $friendlyName,
93        ?string $createdTimestamp,
94        string $secret,
95        string $encryptedSecret = '',
96        string $nonce = ''
97    ) {
98        parent::__construct( $id, $friendlyName, $createdTimestamp );
99        // Currently hardcoded values; might be used in the future
100        $this->secret = [
101            'mode' => 'hotp',
102            'secret' => $secret,
103            'period' => 30,
104            'algorithm' => 'SHA1',
105            'encrypted_secret' => $encryptedSecret,
106            'nonce' => $nonce
107        ];
108    }
109
110    public function getSecret(): string {
111        return $this->secret['secret'];
112    }
113
114    public function setEncryptedSecretAndNonce( string $encryptedSecret, string $nonce ) {
115        $this->secret['encrypted_secret'] = $encryptedSecret;
116        $this->secret['nonce'] = $nonce;
117    }
118
119    public function getEncryptedSecretAndNonce(): array {
120        return [
121            $this->secret['encrypted_secret'],
122            $this->secret['nonce'],
123        ];
124    }
125
126    public function verify( OATHUser $user, array $data ): bool {
127        global $wgOATHAuthWindowRadius;
128
129        $token = $data['token'] ?? '';
130
131        if ( $this->secret['mode'] !== 'hotp' ) {
132            // @codeCoverageIgnoreStart
133            throw new DomainException( 'OATHAuth extension does not support non-HOTP tokens' );
134            // @codeCoverageIgnoreEnd
135        }
136
137        // Prevent replay attacks
138        $services = MediaWikiServices::getInstance();
139        $store = $services->getMainObjectStash();
140
141        if ( $store instanceof EmptyBagOStuff ) {
142            // @codeCoverageIgnoreStart
143            // Try and find some usable cache if the MainObjectStash isn't useful
144            $store = $services->getObjectCacheFactory()->getLocalServerInstance( CACHE_ANYTHING );
145            // @codeCoverageIgnoreEnd
146        }
147
148        $key = $store->makeKey( 'oathauth-totp', 'usedtokens', $user->getCentralId() );
149        $lastWindow = (int)$store->get( $key );
150
151        $results = HOTP::generateByTimeWindow(
152            Base32::decode( $this->secret['secret'] ),
153            $this->secret['period'],
154            -$wgOATHAuthWindowRadius,
155            $wgOATHAuthWindowRadius,
156            (int)ConvertibleTimestamp::now( TimestampFormat::UNIX )
157        );
158
159        // Remove any whitespace from the received token, which can be an intended group separator
160        $token = preg_replace( '/\s+/', '', $token );
161
162        $clientIP = RequestContext::getMain()->getRequest()->getIP();
163
164        $logger = $this->getLogger();
165
166        // Check to see if the user's given token is in the list of tokens generated
167        // for the time window.
168        foreach ( $results as $window => $result ) {
169            if ( $window <= $lastWindow || !hash_equals( $result->toHOTP( 6 ), $token ) ) {
170                continue;
171            }
172
173            $lastWindow = $window;
174
175            $logger->info( 'OATHAuth user {user} entered a valid OTP from {clientip}', [
176                'user' => $user->getAccount(),
177                'clientip' => $clientIP,
178            ] );
179
180            $store->set(
181                $key,
182                $lastWindow,
183                $this->secret['period'] * ( 1 + 2 * $wgOATHAuthWindowRadius )
184            );
185
186            return true;
187        }
188
189        return false;
190    }
191
192    /** @inheritDoc */
193    public function getModule(): string {
194        return TOTP::MODULE_NAME;
195    }
196
197    /**
198     * @return LoggerInterface
199     */
200    private function getLogger(): LoggerInterface {
201        return LoggerFactory::getInstance( 'authentication' );
202    }
203
204    public function jsonSerialize(): array {
205        $encryptedData = $this->getEncryptedSecretAndNonce();
206        $encryptionHelper = self::getEncryptionHelper();
207        if ( $encryptionHelper->isEnabled() && in_array( '', $encryptedData ) ) {
208            $data = $encryptionHelper->encrypt( $this->getSecret() );
209            $this->setEncryptedSecretAndNonce( $data['secret'], $data['nonce'] );
210        } elseif ( $encryptionHelper->isEnabled() ) {
211            $data = [
212                'secret' => $encryptedData[0],
213                'nonce' => $encryptedData[1]
214            ];
215        } else {
216            $data = [ 'secret' => $this->getSecret() ];
217        }
218
219        $data['friendly_name'] = $this->getFriendlyName();
220        return $data;
221    }
222
223    private static function getEncryptionHelper(): EncryptionHelper {
224        return OATHAuthServices::getInstance()->getEncryptionHelper();
225    }
226}