Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 118
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
MWOAuthDataStore
0.00% covered (danger)
0.00%
0 / 118
0.00% covered (danger)
0.00%
0 / 11
1056
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 lookup_consumer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 lookup_token
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
182
 lookup_nonce
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 newToken
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 new_request_token
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 getConsumerKey
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getCallbackUrl
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 new_access_token
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 updateRequestToken
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getRSAKey
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\OAuth\Backend;
4
5use BagOStuff;
6use InvalidArgumentException;
7use MediaWiki\Extension\OAuth\Lib\OAuthConsumer;
8use MediaWiki\Extension\OAuth\Lib\OAuthDataStore;
9use MediaWiki\Linker\Linker;
10use MediaWiki\Logger\LoggerFactory;
11use Message;
12use MWCryptRand;
13use Psr\Log\LoggerInterface;
14use Wikimedia\Rdbms\IDatabase;
15
16class MWOAuthDataStore extends OAuthDataStore {
17    /** @var IDatabase DB for the consumer/grant registry */
18    protected $centralReplica;
19
20    /** @var IDatabase|null Primary DB for repeated lookup in case of replication lag problems;
21     *    null if there is no separate primary DB and replica DB
22     */
23    protected $centralPrimary;
24
25    /** @var BagOStuff Cache for tokens */
26    protected $tokenCache;
27
28    /** @var BagOStuff Cache for nonces */
29    protected $nonceCache;
30
31    /** @var LoggerInterface */
32    protected $logger;
33
34    /**
35     * @param IDatabase $centralReplica Central DB replica
36     * @param IDatabase|null $centralPrimary Central DB primary (if different)
37     * @param BagOStuff $tokenCache
38     * @param BagOStuff $nonceCache
39     */
40    public function __construct(
41        IDatabase $centralReplica,
42        $centralPrimary,
43        BagOStuff $tokenCache,
44        BagOStuff $nonceCache
45    ) {
46        if ( $centralPrimary !== null && !( $centralPrimary instanceof IDatabase ) ) {
47            throw new InvalidArgumentException(
48                __METHOD__ . ': $centralPrimary must be a DB or null'
49            );
50        }
51        $this->centralReplica = $centralReplica;
52        $this->centralPrimary = $centralPrimary;
53        $this->tokenCache = $tokenCache;
54        $this->nonceCache = $nonceCache;
55        $this->logger = LoggerFactory::getInstance( 'OAuth' );
56    }
57
58    /**
59     * Get an MWOAuthConsumer from the consumer's key
60     *
61     * @param string $consumerKey the string value of the Consumer's key
62     * @return Consumer|false
63     */
64    public function lookup_consumer( $consumerKey ) {
65        return Consumer::newFromKey( $this->centralReplica, $consumerKey );
66    }
67
68    /**
69     * Get either a request or access token from the data store
70     *
71     * @param OAuthConsumer|Consumer $consumer
72     * @param string $token_type
73     * @param string $token String the token
74     * @throws MWOAuthException
75     * @return MWOAuthToken
76     */
77    public function lookup_token( $consumer, $token_type, $token ) {
78        $this->logger->debug( __METHOD__ . ": Looking up $token_type token '$token'" );
79
80        if ( $token_type === 'request' ) {
81            $returnToken = $this->tokenCache->get( Utils::getCacheKey(
82                'token',
83                $consumer->key,
84                $token_type,
85                $token
86            ) );
87            if ( $returnToken === '**USED**' ) {
88                throw new MWOAuthException( 'mwoauthdatastore-request-token-already-used', [
89                    Message::rawParam( Linker::makeExternalLink(
90                        'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E009',
91                        'E009',
92                        true
93                    ) ),
94                    'consumer' => $consumer->key,
95                ] );
96            }
97            if ( $token === null || !( $returnToken instanceof MWOAuthToken ) ) {
98                throw new MWOAuthException( 'mwoauthdatastore-request-token-not-found', [
99                    Message::rawParam( Linker::makeExternalLink(
100                        'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E004',
101                        'E004',
102                        true
103                    ) ),
104                    'consumer' => $consumer->key,
105                ] );
106            }
107        } elseif ( $token_type === 'access' ) {
108            $cmra = ConsumerAcceptance::newFromToken( $this->centralReplica, $token );
109            if ( !$cmra && $this->centralPrimary ) {
110                // try primary database in case there is replication lag T124942
111                $cmra = ConsumerAcceptance::newFromToken( $this->centralPrimary, $token );
112            }
113            if ( !$cmra ) {
114                throw new MWOAuthException( 'mwoauthdatastore-access-token-not-found' );
115            }
116
117            // Ensure the cmra's consumer matches the expected consumer (T103023)
118            $mwconsumer = ( $consumer instanceof Consumer )
119                ? $consumer : $this->lookup_consumer( $consumer->key );
120            if ( !$mwconsumer || $mwconsumer->getId() !== $cmra->getConsumerId() ) {
121                throw new MWOAuthException( 'mwoauthdatastore-access-token-not-found', [
122                    'consumer' => $mwconsumer ? $mwconsumer->getConsumerKey() : '',
123                    'cmra_id' => $cmra->getId(),
124                ] );
125            }
126
127            $secret = Utils::hmacDBSecret( $cmra->getAccessSecret() );
128            $returnToken = new MWOAuthToken( $cmra->getAccessToken(), $secret );
129        } else {
130            throw new MWOAuthException( 'mwoauthdatastore-invalid-token-type', [
131                'token_type' => $token_type,
132            ] );
133        }
134
135        return $returnToken;
136    }
137
138    /**
139     * Check that nonce has not been seen before. Add it on check, so we don't repeat it.
140     * Note, timestamp has already been checked, so this should be a fresh nonce.
141     *
142     * @param Consumer|OAuthConsumer $consumer
143     * @param string $token
144     * @param string $nonce
145     * @param int $timestamp
146     * @return bool
147     */
148    public function lookup_nonce( $consumer, $token, $nonce, $timestamp ) {
149        $key = Utils::getCacheKey( 'nonce', $consumer->key, $token, $nonce );
150        // Do an add for the key associated with this nonce to check if it was already used.
151        // Set timeout 5 minutes in the future of the timestamp as OAuthServer does. Use the
152        // timestamp so the client can also expire their nonce records after 5 mins.
153        if ( !$this->nonceCache->add( $key, 1, $timestamp + 300 ) ) {
154            // T308861
155            $key = preg_replace(
156                "/(oauth_token_secret\=\w+:)/",
157                "oauth_token_secret=[REDACTED]:",
158                $key );
159            $this->logger->info( '{key} exists, so nonce has been used by this consumer+token',
160                [ 'key' => $key, 'consumer' => $consumer->key, 'oauth_timestamp' => $timestamp ] );
161            return true;
162        }
163        return false;
164    }
165
166    /**
167     * Helper function to generate and return an MWOAuthToken. MWOAuthToken can be used as a
168     * request or access token.
169     * TODO: put in Utils?
170     * @return MWOAuthToken
171     */
172    public static function newToken() {
173        return new MWOAuthToken(
174            MWCryptRand::generateHex( 32 ),
175            MWCryptRand::generateHex( 32 )
176        );
177    }
178
179    /**
180     * Generate a new token (attached to this consumer), save it in the cache, and return it
181     *
182     * @param Consumer|OAuthConsumer $consumer
183     * @param string $callback
184     * @return MWOAuthToken
185     */
186    public function new_request_token( $consumer, $callback = 'oob' ) {
187        $token = self::newToken();
188        $cacheConsumerKey = Utils::getCacheKey( 'consumer', 'request', $token->key );
189        $cacheTokenKey = Utils::getCacheKey(
190            'token', $consumer->key, 'request', $token->key
191        );
192        $cacheCallbackKey = Utils::getCacheKey(
193            'callback', $consumer->key, 'request', $token->key
194        );
195
196        // 600s == 10 minutes. Kind of arbitrary.
197        $this->tokenCache->add( $cacheConsumerKey, $consumer->key, 600 );
198        $this->tokenCache->add( $cacheTokenKey, $token, 600 );
199        $this->tokenCache->add( $cacheCallbackKey, $callback, 600 );
200        $this->logger->debug( __METHOD__ .
201            ": New request token {$token->key} for {$consumer->key} with callback {$callback}" );
202        return $token;
203    }
204
205    /**
206     * Return a consumer key associated with the given request token.
207     *
208     * @param string $requestToken
209     * @return string|false the consumer key or false if nothing is stored for the request token
210     */
211    public function getConsumerKey( $requestToken ) {
212        $cacheKey = Utils::getCacheKey( 'consumer', 'request', $requestToken );
213        return $this->tokenCache->get( $cacheKey );
214    }
215
216    /**
217     * Return a stored callback URL parameter given by the consumer in /initiate.
218     * It throws an exception if callback URL parameter does not exist in the cache.
219     * A stored callback URL parameter is deleted from the cache once read for the first
220     * time.
221     *
222     * @param string $consumerKey
223     * @param string $requestKey original request key from /initiate
224     * @throws MWOAuthException
225     * @return string|false the stored callback URL parameter
226     */
227    public function getCallbackUrl( $consumerKey, $requestKey ) {
228        $cacheKey = Utils::getCacheKey( 'callback', $consumerKey, 'request', $requestKey );
229        $callback = $this->tokenCache->get( $cacheKey );
230        if ( $callback === null || !is_string( $callback ) ) {
231            throw new MWOAuthException( 'mwoauthdatastore-callback-not-found', [
232                'consumer' => $consumerKey,
233            ] );
234        }
235        $this->tokenCache->delete( $cacheKey );
236        return $callback;
237    }
238
239    /**
240     * Return a new access token attached to this consumer for the user associated with this
241     * token if the request token is authorized. Should also invalidate the request token.
242     *
243     * @param MWOAuthToken $token the request token that started this
244     * @param Consumer $consumer
245     * @param int|null $verifier
246     * @throws MWOAuthException
247     * @return MWOAuthToken the access token
248     */
249    public function new_access_token( $token, $consumer, $verifier = null ) {
250        $this->logger->debug( __METHOD__ .
251            ": Getting new access token for token {$token->key}, consumer {$consumer->key}" );
252
253        if ( !$token->getVerifyCode() || !$token->getAccessKey() ) {
254            throw new MWOAuthException( 'mwoauthdatastore-bad-token', [
255                'consumer' => $consumer->getConsumerKey(),
256                'consumer_name' => $consumer->getName(),
257                'token' => $token->key,
258            ] );
259        } elseif ( $token->getVerifyCode() !== $verifier ) {
260            throw new MWOAuthException( 'mwoauthdatastore-bad-verifier', [
261                'consumer' => $consumer->getConsumerKey(),
262                'consumer_name' => $consumer->getName(),
263                'token' => $token->key,
264            ] );
265        }
266
267        $cacheKey = Utils::getCacheKey( 'token',
268            $consumer->getConsumerKey(), 'request', $token->key );
269        $accessToken = $this->lookup_token( $consumer, 'access', $token->getAccessKey() );
270        $this->tokenCache->set( $cacheKey, '**USED**', 600 );
271        $this->logger->debug( __METHOD__ .
272            ": New access token {$accessToken->key} for {$consumer->key}" );
273        return $accessToken;
274    }
275
276    /**
277     * Update a request token. The token probably already exists, but had another attribute added.
278     *
279     * @param MWOAuthToken $token the token to store
280     * @param Consumer|OAuthConsumer $consumer
281     */
282    public function updateRequestToken( $token, $consumer ) {
283        $cacheKey = Utils::getCacheKey( 'token', $consumer->key, 'request', $token->key );
284        // 10 more minutes. Kind of arbitrary.
285        $this->tokenCache->set( $cacheKey, $token, 600 );
286    }
287
288    /**
289     * Return the string representing the Consumer's public RSA key
290     *
291     * @param string $consumerKey the string value of the Consumer's key
292     * @return string|null
293     */
294    public function getRSAKey( $consumerKey ) {
295        $cmr = Consumer::newFromKey( $this->centralReplica, $consumerKey );
296        return $cmr ? $cmr->getRsaKey() : null;
297    }
298}