Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.94% covered (success)
91.94%
57 / 62
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
NetworkSessionProvider
91.94% covered (success)
91.94%
57 / 62
50.00% covered (danger)
50.00%
5 / 10
29.44
0.00% covered (danger)
0.00%
0 / 1
 __construct
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 provideSessionInfo
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
12
 parseAuthorization
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 newSessionInfoForUser
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedUserRights
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 canAlwaysAutocreate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 persistsSessionId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 canChangeUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 persistSession
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unpersistSession
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * SessionProvider based on configured ip address and secret token
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23namespace MediaWiki\Extension\NetworkSession;
24
25use InvalidArgumentException;
26use MediaWiki\Request\WebRequest;
27use MediaWiki\Session\SessionBackend;
28use MediaWiki\Session\SessionInfo;
29use MediaWiki\Session\SessionProvider;
30use MediaWiki\Session\UserInfo;
31use MediaWiki\WikiMap\WikiMap;
32use Wikimedia\IPUtils;
33
34/**
35 * This provider allows requests from specific ip addresses or ip address
36 * ranges, when they contain a pre-shared secret, to perform api requests.
37 * The set of user rights available to logins through this provider can
38 * be limited, for example to only grant 'read'.
39 *
40 * This is intended to work with applications supporting a wiki farm
41 * allowing them to perform api requests without managing logins for
42 * wikis that don't default to world readable.
43 */
44class NetworkSessionProvider extends SessionProvider {
45    public const AUTH_HEADER = 'Authorization';
46    public const AUTH_SCHEME = 'NetworkSession';
47    public const CAN_ALWAYS_AUTOCREATE_CONFIG_KEY = 'NetworkSessionProviderCanAlwaysAutocreate';
48    public const USERS_CONFIG_KEY = 'NetworkSessionProviderUsers';
49    public const USERS_RIGHTS_CONFIG_KEY = 'NetworkSessionProviderAllowedUserRights';
50
51    /** @var bool Whether the current request is an API request. */
52    private bool $isApiRequest;
53
54    /**
55     * @param array $params Keys include:
56     *  - priority: (required) Set the priority
57     *  - isApiRequest: Whether the current request is an API request. Should be only set in tests.
58     */
59    public function __construct( array $params ) {
60        if ( !isset( $params['priority'] ) ) {
61            throw new InvalidArgumentException( __METHOD__ . ': priority must be specified' );
62        }
63        if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
64            $params['priority'] > SessionInfo::MAX_PRIORITY
65        ) {
66            throw new InvalidArgumentException( __METHOD__ . ': Invalid priority' );
67        }
68        $this->priority = $params['priority'];
69
70        $this->isApiRequest = $params['isApiRequest']
71            ?? ( defined( 'MW_API' ) || defined( 'MW_REST_API' ) );
72    }
73
74    /**
75     * Provide session info for a request
76     *
77     * @param WebRequest $request
78     * @return SessionInfo|null
79     */
80    public function provideSessionInfo( WebRequest $request ) {
81        $providedToken = $this->parseAuthorization( $request->getHeader( self::AUTH_HEADER ) );
82        if ( $providedToken === null ) {
83            return null;
84        }
85
86        if ( !$this->isApiRequest ) {
87            return $this->makeException( 'networksession-only-api-request' );
88        }
89
90        if ( $request->getProtocol() !== 'https' ) {
91            return $this->makeException( 'networksession-only-https' );
92        }
93
94        $ip = $request->getIP();
95        $users = $this->config->get( self::USERS_CONFIG_KEY );
96        $matchedUser = null;
97        foreach ( $users as $config ) {
98            if ( !is_array( $config['ip_ranges'] ?? null ) ) {
99                return $this->makeException( 'networksession-invalid-config-ip-ranges' );
100            }
101            if ( !is_string( $config['token'] ?? null ) ) {
102                return $this->makeException( 'networksession-invalid-config-token' );
103            }
104            if ( !is_string( $config['username'] ?? null ) ) {
105                return $this->makeException( 'networksession-invalid-config-username' );
106            }
107
108            if ( !hash_equals( $config['token'], $providedToken ) ) {
109                continue;
110            }
111            if ( !IPUtils::isInRanges( $ip, $config['ip_ranges'] ) ) {
112                continue;
113            }
114            if ( $matchedUser !== null ) {
115                return $this->makeException( 'networksession-invalid-config-multiple-matches' );
116            }
117            $matchedUser = $config;
118        }
119
120        if ( $matchedUser ) {
121            return $this->newSessionInfoForUser( $matchedUser );
122        } else {
123            return $this->makeException( 'networksession-no-token-match' );
124        }
125    }
126
127    private function parseAuthorization( ?string $authorization ): ?string {
128        if ( $authorization === null ) {
129            return null;
130        }
131        $parts = explode( ' ', $authorization, 2 );
132        if ( count( $parts ) === 2 && strcasecmp( self::AUTH_SCHEME, $parts[0] ) === 0 ) {
133            return $parts[1];
134        }
135        return null;
136    }
137
138    private function newSessionInfoForUser( array $user ): SessionInfo {
139        $id = self::hashToSessionId( implode( '\n', [
140            WikiMap::getCurrentWikiId(),
141            $user['username'],
142            $user['token'],
143        ] ) );
144        return new SessionInfo( $this->priority, [
145            'provider' => $this,
146            'id' => $id,
147            'idIsSafe' => true,
148            'userInfo' => UserInfo::newFromName( $user['username'], true ),
149            'persisted' => false,
150            'forceUse' => true,
151        ] );
152    }
153
154    /**
155     * Fetch the rights allowed to the user when the specified session is active.
156     *
157     * @param SessionBackend $backend
158     * @return null|string[] Allowed user rights, or null to allow all.
159     */
160    public function getAllowedUserRights( SessionBackend $backend ) {
161        if ( $backend->getProvider() !== $this ) {
162            throw new InvalidArgumentException( 'Backend\'s provider isn\'t $this' );
163        }
164        return $this->config->get( self::USERS_RIGHTS_CONFIG_KEY );
165    }
166
167    /**
168     * Declares if this provider is providing system users or regular users
169     *
170     * @return bool
171     */
172    public function canAlwaysAutocreate(): bool {
173        return $this->config->get( self::CAN_ALWAYS_AUTOCREATE_CONFIG_KEY );
174    }
175
176    /**
177     * Indicate whether self::persistSession() can save arbitrary session IDs
178     *
179     * @return bool
180     */
181    public function persistsSessionId() {
182        // session id is calculated, not persisted
183        return false;
184    }
185
186    /**
187     * Indicate whether the user associated with the request can be changed
188     *
189     * @return bool
190     */
191    public function canChangeUser() {
192        return false;
193    }
194
195    /**
196     * Persist a session into a request/response
197     *
198     * @param SessionBackend $session Session to persist
199     * @param WebRequest $request Request into which to persist the session
200     */
201    public function persistSession( SessionBackend $session, WebRequest $request ) {
202        // Nothing to persist
203    }
204
205    /**
206     * Remove any persisted session from a request/response
207     *
208     * @param WebRequest $request Request from which to remove any session data
209     */
210    public function unpersistSession( WebRequest $request ) {
211        // Nothing to unpersist
212    }
213}