summaryrefslogtreecommitdiff
blob: 76c33522563cb41746b3c5568e8880ce808abce2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
<?php

namespace MediaWiki\Extensions\OAuth\Backend;

use MediaWiki\Extensions\OAuth\Lib\OAuthConsumer;
use MediaWiki\Extensions\OAuth\Lib\OAuthDataStore;
use MediaWiki\Logger\LoggerFactory;
use Wikimedia\Rdbms\DBConnRef;

class MWOAuthDataStore extends OAuthDataStore {
	/** @var DBConnRef DB for the consumer/grant registry */
	protected $centralReplica;

	/** @var DBConnRef|null Master DB for repeated lookup in case of replication lag problems;
	 *    null if there is no separate master and replica DB
	 */
	protected $centralMaster;

	/** @var \BagOStuff Cache for Tokens and Nonces */
	protected $cache;

	/** @var \Psr\Log\LoggerInterface */
	protected $logger;

	/**
	 * @param DBConnRef $centralReplica Central DB replica
	 * @param DBConnRef|null $centralMaster Central DB master (if different)
	 * @param \BagOStuff $cache
	 */
	public function __construct( DBConnRef $centralReplica, $centralMaster, \BagOStuff $cache ) {
		if ( $centralMaster !== null && !( $centralMaster instanceof DBConnRef ) ) {
			throw new \InvalidArgumentException(
				__METHOD__ . ': $centralMaster must be a DB or null'
			);
		}
		$this->centralReplica = $centralReplica;
		$this->centralMaster = $centralMaster;
		$this->cache = $cache;
		$this->logger = LoggerFactory::getInstance( 'OAuth' );
	}

	/**
	 * Get an MWOAuthConsumer from the consumer's key
	 *
	 * @param string $consumerKey the string value of the Consumer's key
	 * @return Consumer|bool
	 */
	public function lookup_consumer( $consumerKey ) {
		return Consumer::newFromKey( $this->centralReplica, $consumerKey );
	}

	/**
	 * Get either a request or access token from the data store
	 *
	 * @param OAuthConsumer|Consumer $consumer
	 * @param string $token_type
	 * @param string $token String the token
	 * @throws MWOAuthException
	 * @return MWOAuthToken
	 */
	public function lookup_token( $consumer, $token_type, $token ) {
		$this->logger->debug( __METHOD__ . ": Looking up $token_type token '$token'" );

		if ( $token_type === 'request' ) {
			$returnToken = $this->cache->get( Utils::getCacheKey(
				'token',
				$consumer->key,
				$token_type,
				$token
			) );
			if ( $returnToken === '**USED**' ) {
				throw new MWOAuthException( 'mwoauthdatastore-request-token-already-used', [
					\Message::rawParam( \Linker::makeExternalLink(
						'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E009',
						'E009',
						true
					) )
				] );
			}
			if ( $token === null || !( $returnToken instanceof MWOAuthToken ) ) {
				throw new MWOAuthException( 'mwoauthdatastore-request-token-not-found', [
					\Message::rawParam( \Linker::makeExternalLink(
						'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E004',
						'E004',
						true
					) )
				] );
			}
		} elseif ( $token_type === 'access' ) {
			$cmra = ConsumerAcceptance::newFromToken( $this->centralReplica, $token );
			if ( !$cmra && $this->centralMaster ) {
				// try master in case there is replication lag T124942
				$cmra = ConsumerAcceptance::newFromToken( $this->centralMaster, $token );
			}
			if ( !$cmra ) {
				throw new MWOAuthException( 'mwoauthdatastore-access-token-not-found' );
			}

			// Ensure the cmra's consumer matches the expected consumer (T103023)
			$mwconsumer = ( $consumer instanceof Consumer )
				? $consumer : $this->lookup_consumer( $consumer->key );
			if ( !$mwconsumer || $mwconsumer->getId() !== $cmra->getConsumerId() ) {
				throw new MWOAuthException( 'mwoauthdatastore-access-token-not-found' );
			}

			$secret = Utils::hmacDBSecret( $cmra->getAccessSecret() );
			$returnToken = new MWOAuthToken( $cmra->getAccessToken(), $secret );
		} else {
			throw new MWOAuthException( 'mwoauthdatastore-invalid-token-type' );
		}

		return $returnToken;
	}

	/**
	 * Check that nonce has not been seen before. Add it on check, so we don't repeat it.
	 * Note, timestamp has already been checked, so this should be a fresh nonce.
	 *
	 * @param Consumer|OAuthConsumer $consumer
	 * @param string $token
	 * @param string $nonce
	 * @param int $timestamp
	 * @return bool
	 */
	public function lookup_nonce( $consumer, $token, $nonce, $timestamp ) {
		$key = Utils::getCacheKey( 'nonce', $consumer->key, $token, $nonce );
		// Do an add for the key associated with this nonce to check if it was already used.
		// Set timeout 5 minutes in the future of the timestamp as OAuthServer does. Use the
		// timestamp so the client can also expire their nonce records after 5 mins.
		if ( !$this->cache->add( $key, 1, $timestamp + 300 ) ) {
			$this->logger->info( "$key exists, so nonce has been used by this consumer+token" );
			return true;
		}
		return false;
	}

	/**
	 * Helper function to generate and return an MWOAuthToken. MWOAuthToken can be used as a
	 * request or access token.
	 * TODO: put in Utils?
	 * @return MWOAuthToken
	 */
	public static function newToken() {
		return new MWOAuthToken(
			\MWCryptRand::generateHex( 32 ),
			\MWCryptRand::generateHex( 32 )
		);
	}

	/**
	 * Generate a new token (attached to this consumer), save it in the cache, and return it
	 *
	 * @param Consumer|OAuthConsumer $consumer
	 * @param string $callback
	 * @return MWOAuthToken
	 */
	public function new_request_token( $consumer, $callback = 'oob' ) {
		$token = self::newToken();
		$cacheConsumerKey = Utils::getCacheKey( 'consumer', 'request', $token->key );
		$cacheTokenKey = Utils::getCacheKey(
			'token', $consumer->key, 'request', $token->key
		);
		$cacheCallbackKey = Utils::getCacheKey(
			'callback', $consumer->key, 'request', $token->key
		);
		$this->cache->add( $cacheConsumerKey, $consumer->key, 600 ); // 10 minutes. Kindof arbitray.
		$this->cache->add( $cacheTokenKey, $token, 600 ); // 10 minutes. Kindof arbitray.
		$this->cache->add( $cacheCallbackKey, $callback, 600 ); // 10 minutes. Kindof arbitray.
		$this->logger->debug( __METHOD__ .
			": New request token {$token->key} for {$consumer->key} with callback {$callback}" );
		return $token;
	}

	/**
	 * Return a consumer key associated with the given request token.
	 *
	 * @param MWOAuthToken $requestToken the request token
	 * @return string|false the consumer key or false if nothing is stored for the request token
	 */
	public function getConsumerKey( $requestToken ) {
		$cacheKey = Utils::getCacheKey( 'consumer', 'request', $requestToken );
		$consumerKey = $this->cache->get( $cacheKey );
		return $consumerKey;
	}

	/**
	 * Return a stored callback URL parameter given by the consumer in /initiate.
	 * It throws an exception if callback URL parameter does not exist in the cache.
	 * A stored callback URL parameter is deleted from the cache once read for the first
	 * time.
	 *
	 * @param string $consumerKey the consumer key
	 * @param string $requestKey original request key from /initiate
	 * @throws MWOAuthException
	 * @return string|false the stored callback URL parameter
	 */
	public function getCallbackUrl( $consumerKey, $requestKey ) {
		$cacheKey = Utils::getCacheKey( 'callback', $consumerKey, 'request', $requestKey );
		$callback = $this->cache->get( $cacheKey );
		if ( $callback === null || !is_string( $callback ) ) {
			throw new MWOAuthException( 'mwoauthdatastore-callback-not-found' );
		}
		$this->cache->delete( $cacheKey );
		return $callback;
	}

	/**
	 * Return a new access token attached to this consumer for the user associated with this
	 * token if the request token is authorized. Should also invalidate the request token.
	 *
	 * @param MWOAuthToken $token the request token that started this
	 * @param Consumer $consumer
	 * @param int|null $verifier
	 * @throws MWOAuthException
	 * @return MWOAuthToken the access token
	 */
	public function new_access_token( $token, $consumer, $verifier = null ) {
		$this->logger->debug( __METHOD__ .
			": Getting new access token for token {$token->key}, consumer {$consumer->key}" );

		if ( !$token->getVerifyCode() || !$token->getAccessKey() ) {
			throw new MWOAuthException( 'mwoauthdatastore-bad-token' );
		} elseif ( $token->getVerifyCode() !== $verifier ) {
			throw new MWOAuthException( 'mwoauthdatastore-bad-verifier' );
		}

		$cacheKey = Utils::getCacheKey( 'token',
			$consumer->getConsumerKey(), 'request', $token->key );
		$accessToken = $this->lookup_token( $consumer, 'access', $token->getAccessKey() );
		$this->cache->set( $cacheKey, '**USED**', 600 );
		$this->logger->debug( __METHOD__ .
			": New access token {$accessToken->key} for {$consumer->key}" );
		return $accessToken;
	}

	/**
	 * Update a request token. The token probably already exists, but had another attribute added.
	 *
	 * @param MWOAuthToken $token the token to store
	 * @param Consumer|OAuthConsumer $consumer
	 */
	public function updateRequestToken( $token, $consumer ) {
		$cacheKey = Utils::getCacheKey( 'token', $consumer->key, 'request', $token->key );
		$this->cache->set( $cacheKey, $token, 600 ); // 10 more minutes. Kindof arbitray.
	}

	/**
	 * Return the string representing the Consumer's public RSA key
	 *
	 * @param string $consumerKey the string value of the Consumer's key
	 * @return string|null
	 */
	public function getRSAKey( $consumerKey ) {
		$cmr = Consumer::newFromKey( $this->centralReplica, $consumerKey );
		return $cmr ? $cmr->getRsaKey() : null;
	}
}