Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
CentralAuthTokenManager
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 8
210
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 makeTokenKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 makeLegacyTokenKey
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 tokenize
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 detokenize
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 detokenizeAndDelete
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getKeyValueUponExistence
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 getCentralAuthDBForSessionKey
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\CentralAuth;
4
5use MediaWiki\MediaWikiServices;
6use MWCryptRand;
7use Psr\Log\LoggerInterface;
8use Psr\Log\NullLogger;
9use Wikimedia\Assert\Assert;
10use Wikimedia\LightweightObjectStore\ExpirationAwareness;
11use Wikimedia\ObjectCache\BagOStuff;
12use Wikimedia\WaitConditionLoop;
13
14class 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}