Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 48 |
|
0.00% |
0 / 8 |
CRAP | |
0.00% |
0 / 1 |
CentralAuthTokenManager | |
0.00% |
0 / 48 |
|
0.00% |
0 / 8 |
210 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
makeTokenKey | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
makeLegacyTokenKey | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
tokenize | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
detokenize | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
detokenizeAndDelete | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
getKeyValueUponExistence | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
30 | |||
getCentralAuthDBForSessionKey | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\CentralAuth; |
4 | |
5 | use MediaWiki\MediaWikiServices; |
6 | use MWCryptRand; |
7 | use Psr\Log\LoggerInterface; |
8 | use Psr\Log\NullLogger; |
9 | use Wikimedia\Assert\Assert; |
10 | use Wikimedia\LightweightObjectStore\ExpirationAwareness; |
11 | use Wikimedia\ObjectCache\BagOStuff; |
12 | use Wikimedia\WaitConditionLoop; |
13 | |
14 | class CentralAuthTokenManager { |
15 | |
16 | private BagOStuff $tokenStore; |
17 | private LoggerInterface $logger; |
18 | |
19 | /** |
20 | * @param BagOStuff $tokenStore Store for short-lived tokens used during authentication. |
21 | * Typically {@see MediaWikiServices::getMicroStash()}. |
22 | */ |
23 | public function __construct( |
24 | BagOStuff $tokenStore, |
25 | ?LoggerInterface $logger = null |
26 | ) { |
27 | $this->tokenStore = $tokenStore; |
28 | $this->logger = $logger ?? new NullLogger(); |
29 | } |
30 | |
31 | /** |
32 | * @param string $keygroup |
33 | * @param string ...$components |
34 | * @return string The global token key (with proper escaping) |
35 | */ |
36 | private function makeTokenKey( string $keygroup, ...$components ): string { |
37 | return $this->tokenStore->makeGlobalKey( |
38 | $keygroup, $this->getCentralAuthDBForSessionKey(), ...$components |
39 | ); |
40 | } |
41 | |
42 | /** |
43 | * Some existing keys both pre- and postfix the token. |
44 | * |
45 | * @param string|array $namespace Key parts; the first goes before the token, the rest go after. |
46 | * @param string $token |
47 | * @return string |
48 | */ |
49 | private function makeLegacyTokenKey( $namespace, $token ): string { |
50 | if ( is_array( $namespace ) ) { |
51 | $head = array_shift( $namespace ); |
52 | $tail = $namespace; |
53 | } else { |
54 | $head = $namespace; |
55 | $tail = []; |
56 | } |
57 | return $this->makeTokenKey( $head, $token, ...$tail ); |
58 | } |
59 | |
60 | /** |
61 | * Store a value for a short time via the shared token store, and return the random key it's |
62 | * stored under. This can be used to pass data between requests in a redirect chain via a |
63 | * random URL token that cannot be sniffed or tampered with. |
64 | * |
65 | * An attacker can start the process that involves generating the token, but then instead of |
66 | * following the redirect, tricking a victim to follow it, e.g. to set up a session fixation |
67 | * attack. It is the caller's responsibility to handle this threat. |
68 | * |
69 | * @param mixed $data The value to store. Must be serializable and can't be boolean false. |
70 | * @param string|array $keyPrefix Namespace in the token store. |
71 | * @param array $options Options: |
72 | * - expiry (int, default 60): Expiration time of the token store record in seconds. |
73 | * - token(string): Reuse the given token (presumably one from an earlier tokenize() |
74 | * call) instead of generating a new random token. |
75 | * @return string The random key (without the prefix). |
76 | */ |
77 | public function tokenize( |
78 | $data, |
79 | $keyPrefix, |
80 | array $options = [] |
81 | ): string { |
82 | Assert::parameter( $data !== false, '$data', 'cannot be boolean false' ); |
83 | $expiry = $options['expiry'] ?? ExpirationAwareness::TTL_MINUTE; |
84 | $token = $options['token'] ?? MWCryptRand::generateHex( 32 ); |
85 | $key = $this->makeLegacyTokenKey( $keyPrefix, $token ); |
86 | $this->tokenStore->set( $key, $data, $expiry ); |
87 | return $token; |
88 | } |
89 | |
90 | /** |
91 | * Recover the value concealed with tokenize(). |
92 | * |
93 | * The value is left in the store. It is the caller's responsibility to prevent replay attacks. |
94 | * |
95 | * @param string $token The random key returned by tokenize(). |
96 | * @param string|array $keyPrefix Namespace in the token store. |
97 | * @param array $options Options: |
98 | * - timeout (int, default 3): Seconds to wait for the token store record to be created |
99 | * by another thread, when the first lookup doesn't find it. |
100 | * @return mixed|false The value, or false if it was not found. |
101 | */ |
102 | public function detokenize( |
103 | string $token, |
104 | $keyPrefix, |
105 | array $options = [] |
106 | ) { |
107 | $timeout = $options['timeout'] ?? 3; |
108 | $key = $this->makeLegacyTokenKey( $keyPrefix, $token ); |
109 | return $this->getKeyValueUponExistence( $key, $timeout ); |
110 | } |
111 | |
112 | /** |
113 | * Recover the value concealed with tokenize(), and delete it from the store. |
114 | * |
115 | * @param string $token The random key returned by tokenize(). |
116 | * @param string|array $keyPrefix Namespace in the token store. |
117 | * @param array $options Options: |
118 | * * - timeout (int, default 3): Seconds to wait for the token store record to be created |
119 | * * by another thread, when the first lookup doesn't find it. |
120 | * @return mixed|false The value, or false if it was not found. |
121 | */ |
122 | public function detokenizeAndDelete( |
123 | string $token, |
124 | $keyPrefix, |
125 | array $options = [] |
126 | ) { |
127 | $key = $this->makeLegacyTokenKey( $keyPrefix, $token ); |
128 | $value = $this->detokenize( $token, $keyPrefix, $options ); |
129 | if ( $value !== false ) { |
130 | $this->tokenStore->delete( $key ); |
131 | } |
132 | return $value; |
133 | } |
134 | |
135 | /** |
136 | * Wait for and return the value of a key which is expected to exist from a store |
137 | * |
138 | * @param string $key A key that will only have one value while it exists |
139 | * @param int $timeout |
140 | * @return mixed Key value; false if not found or on error |
141 | */ |
142 | private function getKeyValueUponExistence( $key, $timeout = 3 ) { |
143 | $value = false; |
144 | |
145 | $result = ( new WaitConditionLoop( |
146 | function () use ( $key, &$value ) { |
147 | $store = $this->tokenStore; |
148 | $watchPoint = $store->watchErrors(); |
149 | $value = $store->get( $key ); |
150 | $error = $store->getLastError( $watchPoint ); |
151 | if ( $value !== false ) { |
152 | return WaitConditionLoop::CONDITION_REACHED; |
153 | } elseif ( $error === $store::ERR_NONE ) { |
154 | return WaitConditionLoop::CONDITION_CONTINUE; |
155 | } else { |
156 | return WaitConditionLoop::CONDITION_ABORTED; |
157 | } |
158 | }, |
159 | $timeout |
160 | ) )->invoke(); |
161 | |
162 | if ( $result === WaitConditionLoop::CONDITION_REACHED ) { |
163 | $this->logger->info( "Expected key {key} found.", [ 'key' => $key ] ); |
164 | } elseif ( $result === WaitConditionLoop::CONDITION_TIMED_OUT ) { |
165 | $this->logger->error( "Expected key {key} not found due to timeout.", [ 'key' => $key ] ); |
166 | } else { |
167 | $this->logger->error( "Expected key {key} not found due to I/O error.", [ 'key' => $key ] ); |
168 | } |
169 | |
170 | return $value; |
171 | } |
172 | |
173 | /** |
174 | * @return string db name, for session key creation |
175 | * Note that if there is more than one CentralAuth database |
176 | * in use for the same session key store, the database names |
177 | * MUST be unique. |
178 | */ |
179 | private function getCentralAuthDBForSessionKey() { |
180 | return MediaWikiServices::getInstance() |
181 | ->getDBLoadBalancerFactory()->getPrimaryDatabase( 'virtual-centralauth' )->getDomainID(); |
182 | } |
183 | |
184 | } |