Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
33.57% covered (danger)
33.57%
48 / 143
27.27% covered (danger)
27.27%
3 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
MWOAuthServer
33.57% covered (danger)
33.57%
48 / 143
27.27% covered (danger)
27.27%
3 / 11
394.17
0.00% covered (danger)
0.00%
0 / 1
 getConsumerKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fetch_request_token
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
20
 checkCallback
79.07% covered (warning)
79.07%
34 / 43
0.00% covered (danger)
0.00%
0 / 1
13.32
 looseSchemeMatch
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getOrNull
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 componentMatches
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 fetch_access_token
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
30
 verify_request
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 checkSourceIP
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 authorize
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getCurrentAuthorization
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\OAuth\Backend;
4
5use MediaWiki\Extension\OAuth\Lib\OAuthServer;
6use MediaWiki\Linker\Linker;
7use MediaWiki\SpecialPage\SpecialPage;
8use MediaWiki\User\User;
9use Message;
10
11class MWOAuthServer extends OAuthServer {
12    /** @var MWOAuthDataStore */
13    protected $data_store;
14
15    /**
16     * Return a consumer key associated with the given request token.
17     *
18     * @param string $requestToken
19     * @return string|false the consumer key or false if nothing is stored for the request token
20     */
21    public function getConsumerKey( $requestToken ) {
22        return $this->data_store->getConsumerKey( $requestToken );
23    }
24
25    /**
26     * Process a request_token request returns the request token on success. This
27     * also checks the IP restriction, which the OAuthServer method did not.
28     *
29     * @param MWOAuthRequest &$request
30     * @return MWOAuthToken
31     * @throws MWOAuthException
32     */
33    public function fetch_request_token( &$request ) {
34        $this->get_version( $request );
35
36        /** @var Consumer $consumer */
37        $consumer = $this->get_consumer( $request );
38
39        // Consumer must not be owner-only
40        if ( $consumer->getOwnerOnly() ) {
41            throw new MWOAuthException( 'mwoauthserver-consumer-owner-only', [
42                'consumer_name' => $consumer->getName(),
43                'update_url' => SpecialPage::getTitleFor(
44                    'OAuthConsumerRegistration', 'update/' . $consumer->getConsumerKey()
45                ),
46                Message::rawParam( Linker::makeExternalLink(
47                    'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E010',
48                    'E010',
49                    true
50                ) ),
51                'consumer' => $consumer->getConsumerKey(),
52            ] );
53        }
54
55        // Consumer must have a key for us to verify
56        if ( !$consumer->getSecretKey() && !$consumer->getRsaKey() ) {
57            throw new MWOAuthException( 'mwoauthserver-consumer-no-secret', [
58                Message::rawParam( Linker::makeExternalLink(
59                    'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E011',
60                    'E011',
61                    true
62                ) ),
63                'consumer' => $consumer->getConsumerKey(),
64                'consumer_name' => $consumer->getName(),
65            ] );
66        }
67
68        $this->checkSourceIP( $consumer, $request );
69
70        // no token required for the initial token request
71        $token = null;
72
73        $this->check_signature( $request, $consumer, $token );
74
75        $callback = $request->get_parameter( 'oauth_callback' );
76
77        $this->checkCallback( $consumer, $callback );
78
79        $new_token = $this->data_store->new_request_token( $consumer, $callback );
80        $new_token->oauth_callback_confirmed = 'true';
81        return $new_token;
82    }
83
84    /**
85     * Ensure the callback is "oob" or that the registered callback is a valid
86     * prefix of the supplied callback. It throws an exception if callback is
87     * invalid.
88     *
89     * In MediaWiki, we require the callback to be established at
90     * registration. OAuth 1.0a (rfc5849, section 2.1) specifies that
91     * oauth_callback is required for the temporary credentials, and "If the
92     * client is unable to receive callbacks or a callback URI has been
93     * established via other means, the parameter value MUST be set to "oob"
94     * (case sensitive), to indicate an out-of-band configuration." Otherwise,
95     * client can provide a callback and the configured callback must be
96     * a prefix of the supplied callback. The matching performed here is based
97     * on parsed URL components rather than strict string matching. Protocol
98     * upgrades from http to https are also allowed, and the registered callback
99     * can be made to match any port number, by specifying port 1. (This is
100     * less secure, and only meant for demo consumers for local development.)
101     *
102     * @param Consumer $consumer
103     * @param string $callback
104     * @return void
105     * @throws MWOAuthException
106     */
107    private function checkCallback( $consumer, $callback ) {
108        if ( !$consumer->getCallbackIsPrefix() ) {
109            if ( $callback !== 'oob' ) {
110                throw new MWOAuthException( 'mwoauth-callback-not-oob', [
111                    'consumer' => $consumer->getConsumerKey(),
112                    'consumer_name' => $consumer->getName(),
113                    'callback_url' => $callback,
114                ] );
115            }
116
117            return;
118        }
119
120        if ( !$callback ) {
121            throw new MWOAuthException( 'mwoauth-callback-not-oob-or-prefix', [
122                'consumer' => $consumer->getConsumerKey(),
123                'consumer_name' => $consumer->getName(),
124            ] );
125        }
126        if ( $callback === 'oob' ) {
127            return;
128        }
129
130        $reqCallback = wfParseUrl( $callback );
131        if ( $reqCallback === false ) {
132            throw new MWOAuthException( 'mwoauth-callback-not-oob-or-prefix', [
133                'consumer' => $consumer->getConsumerKey(),
134                'consumer_name' => $consumer->getName(),
135                'callback_url' => $callback,
136            ] );
137        }
138
139        $knownCallback = wfParseUrl( $consumer->getCallbackUrl() );
140        $exactPath = array_key_exists( 'query', $knownCallback );
141
142        $match =
143            // Protocol can be upgraded from http to https
144            self::looseSchemeMatch( $knownCallback['scheme'], $reqCallback['scheme'] ) &&
145            // Host must match exactly
146            $knownCallback['host'] === $reqCallback['host'] &&
147            // Port must be either missing from both or an exact match,
148            // unless the registered callback allows any port, which is specified
149            // by using port 1.
150            ( static::getOrNull( 'port', $knownCallback ) === 1 ||
151                static::getOrNull( 'port', $knownCallback ) ===
152                    static::getOrNull( 'port', $reqCallback )
153            ) &&
154            // Path must be an exact match if query is provided in the
155            // registered callback. Otherwise it must be a prefix match if
156            // provided in the registered callback or anything if no path was
157            // included in the registered callback at all.
158            static::componentMatches( 'path', $knownCallback, $reqCallback, $exactPath ) &&
159            // Query string must be aprefix match if provided in the
160            // registered callback.
161            static::componentMatches( 'query', $knownCallback, $reqCallback );
162
163        if ( !$match ) {
164            throw new MWOAuthException( 'mwoauth-callback-not-oob-or-prefix', [
165                'consumer' => $consumer->getConsumerKey(),
166                'consumer_name' => $consumer->getName(),
167                'callback_url' => $callback,
168                'consumer_callback_prefix' => $consumer->getCallbackUrl(),
169            ] );
170        }
171    }
172
173    /**
174     * Compare URL schemes for a match.
175     *
176     * Allows 'https' to match an expected 'http' value.
177     *
178     * @param string $want
179     * @param string $got
180     * @return bool
181     */
182    private static function looseSchemeMatch( $want, $got ) {
183        if ( $want === 'http' ) {
184            return in_array( $got, [ 'http', 'https' ], true );
185        } else {
186            return $want === $got;
187        }
188    }
189
190    /**
191     * Get a named value from an array or return null if the key does not
192     * exist.
193     *
194     * @param string $key
195     * @param array $arr
196     * @return mixed
197     */
198    private static function getOrNull( $key, $arr ) {
199        return array_key_exists( $key, $arr ) ? $arr[$key] : null;
200    }
201
202    /**
203     * Check that a callback URL component matches the expected value.
204     *
205     * @param string $part URL component name
206     * @param array $expect Expected URL components
207     * @param array $got Posted URl components
208     * @param bool $exact Perform exact match instead of prefix match
209     * @return bool
210     */
211    private static function componentMatches(
212        $part, $expect, $got, $exact = false
213    ) {
214        if ( !array_key_exists( $part, $expect ) ) {
215            // Anything in the request is ok if we do not have the URL part in
216            // the expected values
217            $match = true;
218        } elseif ( !array_key_exists( $part, $got ) ) {
219            $match = false;
220        } elseif ( $exact ) {
221            $match = $expect[$part] === $got[$part];
222        } else {
223            $want = (string)$expect[$part];
224            $have = (string)$got[$part];
225            $match = strpos( $have, $want ) === 0;
226        }
227        return $match;
228    }
229
230    /**
231     * process an access_token request
232     * returns the access token on success
233     *
234     * @param MWOAuthRequest &$request
235     * @return MWOAuthToken
236     * @throws MWOAuthException
237     */
238    public function fetch_access_token( &$request ) {
239        $this->get_version( $request );
240
241        /** @var Consumer $consumer */
242        $consumer = $this->get_consumer( $request );
243
244        // Consumer must not be owner-only
245        if ( $consumer->getOwnerOnly() ) {
246            throw new MWOAuthException( 'mwoauthserver-consumer-owner-only', [
247                'consumer_name' => $consumer->getName(),
248                'update_url' => SpecialPage::getTitleFor(
249                    'OAuthConsumerRegistration', 'update/' . $consumer->getConsumerKey()
250                ),
251                Message::rawParam( Linker::makeExternalLink(
252                    'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E010',
253                    'E010',
254                    true
255                ) ),
256                'consumer' => $consumer->getConsumerKey(),
257            ] );
258        }
259
260        // Consumer must have a key for us to verify
261        if ( !$consumer->getSecretKey() && !$consumer->getRsaKey() ) {
262            throw new MWOAuthException( 'mwoauthserver-consumer-no-secret', [
263                Message::rawParam( Linker::makeExternalLink(
264                    'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E011',
265                    'E011',
266                    true
267                ) ),
268                'consumer' => $consumer->getConsumerKey(),
269                'consumer_name' => $consumer->getName(),
270            ] );
271        }
272
273        $this->checkSourceIP( $consumer, $request );
274
275        // requires authorized request token
276        /** @var MWOAuthToken $token */
277        $token = $this->get_token( $request, $consumer, 'request' );
278
279        if ( !$token->secret ) {
280            // This token has a blank secret.. something is wrong
281            throw new MWOAuthException( 'mwoauthdatastore-bad-token', [
282                'consumer' => $consumer->getConsumerKey(),
283                'consumer_name' => $consumer->getName(),
284                'token' => $token->key,
285            ] );
286        }
287
288        $this->check_signature( $request, $consumer, $token );
289
290        // Rev A change
291        $verifier = $request->get_parameter( 'oauth_verifier' );
292        $this->logger->debug( __METHOD__ . ": verify code is '$verifier'" );
293        return $this->data_store->new_access_token( $token, $consumer, $verifier );
294    }
295
296    /**
297     * Wrap the call to the parent function and check that the source IP of
298     * the request is allowed by this consumer's restrictions.
299     * @param MWOAuthRequest &$request
300     * @return array
301     */
302    public function verify_request( &$request ) {
303        [ $consumer, $token ] = parent::verify_request( $request );
304        $this->checkSourceIP( $consumer, $request );
305        return [ $consumer, $token ];
306    }
307
308    /**
309     * Ensure the request comes from an approved IP address, if IP restriction has been
310     * setup by the Consumer. It throws an exception if IP address is invalid.
311     *
312     * @param Consumer $consumer
313     * @param MWOAuthRequest $request
314     * @throws MWOAuthException
315     */
316    private function checkSourceIP( $consumer, $request ) {
317        $restrictions = $consumer->getRestrictions();
318        if ( !$restrictions->checkIP( $request->getSourceIP() ) ) {
319            throw new MWOAuthException( 'mwoauthdatastore-bad-source-ip', [
320                'consumer' => $consumer->getConsumerKey(),
321                'consumer_name' => $consumer->getName(),
322                'request_ip' => $request->getSourceIP(),
323            ] );
324        }
325    }
326
327    /**
328     * @deprecated User MWOAuthConsumer::authorize(...)
329     *
330     * @param string $consumerKey
331     * @param string $requestTokenKey
332     * @param User $mwUser
333     * @param bool $update
334     * @return string
335     */
336    public function authorize( $consumerKey, $requestTokenKey, User $mwUser, $update ) {
337        $dbr = Utils::getCentralDB( DB_REPLICA );
338        $consumer = Consumer::newFromKey( $dbr, $consumerKey );
339        return $consumer->authorize( $mwUser, $update, $consumer->getGrants(), $requestTokenKey );
340    }
341
342    /**
343     * Attempts to find an authorization by this user for this consumer. Since a user can
344     * accept a consumer multiple times (once for "*" and once for each specific wiki),
345     * there can several access tokens per-wiki (with varying grants) for a consumer.
346     * This will choose the most wiki-specific access token. The precedence is:
347     * a) The acceptance for wiki X if the consumer is applicable only to wiki X
348     * b) The acceptance for wiki $wikiId (if the consumer is applicable to it)
349     * c) The acceptance for wikis "*" (all wikis)
350     *
351     * Users might want more grants on some wikis than on "*". Note that the reverse would not
352     * make sense, since the consumer could just use the "*" acceptance if it has more grants.
353     *
354     * @param User $mwUser (local wiki user) User who may or may not have authorizations
355     * @param Consumer $consumer
356     * @param string $wikiId
357     * @throws MWOAuthException
358     * @return ConsumerAcceptance
359     * @deprecated Use MWOAuthConsumer::getCurrentAuthorization(...)
360     */
361    public function getCurrentAuthorization( User $mwUser, $consumer, $wikiId ) {
362        wfDeprecated( __METHOD__ );
363        return $consumer->getCurrentAuthorization( $mwUser, $wikiId );
364    }
365}