Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.75% covered (success)
91.75%
89 / 97
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
BotPasswordSessionProvider
91.75% covered (success)
91.75%
89 / 97
85.71% covered (warning)
85.71%
6 / 7
28.44
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 provideSessionInfo
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 newSessionInfo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newSessionForRequest
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 refreshSessionInfo
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
5
 preventSessionsForUser
n/a
0 / 0
n/a
0 / 0
1
 getAllowedUserRights
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 getRestrictions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * Session provider for bot passwords
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 * @ingroup Session
22 */
23
24namespace MediaWiki\Session;
25
26use MediaWiki\MainConfigNames;
27use MediaWiki\Permissions\GrantsInfo;
28use MediaWiki\Request\WebRequest;
29use MediaWiki\User\BotPassword;
30use MediaWiki\User\User;
31use MWRestrictions;
32
33/**
34 * Session provider for bot passwords
35 * @since 1.27
36 */
37class BotPasswordSessionProvider extends ImmutableSessionProviderWithCookie {
38    /** @var GrantsInfo */
39    private $grantsInfo;
40
41    /** @var bool Whether the current request is an API request. */
42    private $isApiRequest;
43
44    /**
45     * @param GrantsInfo $grantsInfo
46     * @param array $params Keys include:
47     *  - priority: (required) Set the priority
48     *  - sessionCookieName: Session cookie name. Default is '_BPsession'.
49     *  - sessionCookieOptions: Options to pass to WebResponse::setCookie().
50     *  - isApiRequest: Whether the current request is an API request. Should be only set in tests.
51     */
52    public function __construct( GrantsInfo $grantsInfo, array $params = [] ) {
53        if ( !isset( $params['sessionCookieName'] ) ) {
54            $params['sessionCookieName'] = '_BPsession';
55        }
56        parent::__construct( $params );
57
58        if ( !isset( $params['priority'] ) ) {
59            throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
60        }
61        if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
62            $params['priority'] > SessionInfo::MAX_PRIORITY
63        ) {
64            throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
65        }
66
67        $this->priority = $params['priority'];
68
69        $this->grantsInfo = $grantsInfo;
70
71        $this->isApiRequest = $params['isApiRequest']
72            ?? ( defined( 'MW_API' ) || defined( 'MW_REST_API' ) );
73    }
74
75    public function provideSessionInfo( WebRequest $request ) {
76        // Only relevant for the (Action or REST) API
77        if ( !$this->isApiRequest ) {
78            return null;
79        }
80
81        // Enabled?
82        if ( !$this->getConfig()->get( MainConfigNames::EnableBotPasswords ) ) {
83            return null;
84        }
85
86        // Have a session ID?
87        $id = $this->getSessionIdFromCookie( $request );
88        if ( $id === null ) {
89            return null;
90        }
91
92        return new SessionInfo( $this->priority, [
93            'provider' => $this,
94            'id' => $id,
95            'persisted' => true
96        ] );
97    }
98
99    public function newSessionInfo( $id = null ) {
100        // We don't activate by default
101        return null;
102    }
103
104    /**
105     * Create a new session for a request
106     * @param User $user
107     * @param BotPassword $bp
108     * @param WebRequest $request
109     * @return Session
110     */
111    public function newSessionForRequest( User $user, BotPassword $bp, WebRequest $request ) {
112        $id = $this->getSessionIdFromCookie( $request );
113        $info = new SessionInfo( SessionInfo::MAX_PRIORITY, [
114            'provider' => $this,
115            'id' => $id,
116            'userInfo' => UserInfo::newFromUser( $user, true ),
117            'persisted' => $id !== null,
118            'metadata' => [
119                'centralId' => $bp->getUserCentralId(),
120                'appId' => $bp->getAppId(),
121                'token' => $bp->getToken(),
122                'rights' => $this->grantsInfo->getGrantRights( $bp->getGrants() ),
123                'restrictions' => $bp->getRestrictions()->toJson(),
124            ],
125        ] );
126        $session = $this->getManager()->getSessionFromInfo( $info, $request );
127        $session->persist();
128        return $session;
129    }
130
131    /**
132     * @inheritDoc
133     * @phan-param array &$metadata
134     */
135    public function refreshSessionInfo( SessionInfo $info, WebRequest $request, &$metadata ) {
136        $missingKeys = array_diff(
137            [ 'centralId', 'appId', 'token' ],
138            array_keys( $metadata )
139        );
140        if ( $missingKeys ) {
141            $this->logger->info( 'Session "{session}": Missing metadata: {missing}', [
142                'session' => $info->__toString(),
143                'missing' => implode( ', ', $missingKeys ),
144            ] );
145            return false;
146        }
147
148        $bp = BotPassword::newFromCentralId( $metadata['centralId'], $metadata['appId'] );
149        if ( !$bp ) {
150            $this->logger->info(
151                'Session "{session}": No BotPassword for {centralId} {appId}',
152                [
153                    'session' => $info->__toString(),
154                    'centralId' => $metadata['centralId'],
155                    'appId' => $metadata['appId'],
156                ] );
157            return false;
158        }
159
160        if ( !hash_equals( $metadata['token'], $bp->getToken() ) ) {
161            $this->logger->info( 'Session "{session}": BotPassword token check failed', [
162                'session' => $info->__toString(),
163                'centralId' => $metadata['centralId'],
164                'appId' => $metadata['appId'],
165            ] );
166            return false;
167        }
168
169        $status = $bp->getRestrictions()->check( $request );
170        if ( !$status->isOK() ) {
171            $this->logger->info(
172                'Session "{session}": Restrictions check failed',
173                [
174                    'session' => $info->__toString(),
175                    'restrictions' => $status->getValue(),
176                    'centralId' => $metadata['centralId'],
177                    'appId' => $metadata['appId'],
178                ] );
179            return false;
180        }
181
182        // Update saved rights
183        $metadata['rights'] = $this->grantsInfo->getGrantRights( $bp->getGrants() );
184
185        return true;
186    }
187
188    /**
189     * @codeCoverageIgnore
190     * @inheritDoc
191     */
192    public function preventSessionsForUser( $username ) {
193        BotPassword::removeAllPasswordsForUser( $username );
194    }
195
196    public function getAllowedUserRights( SessionBackend $backend ) {
197        if ( $backend->getProvider() !== $this ) {
198            throw new \InvalidArgumentException( 'Backend\'s provider isn\'t $this' );
199        }
200        $data = $backend->getProviderMetadata();
201        if ( $data && isset( $data['rights'] ) && is_array( $data['rights'] ) ) {
202            return $data['rights'];
203        }
204
205        // Should never happen
206        $this->logger->debug( __METHOD__ . ': No provider metadata, returning no rights allowed' );
207        return [];
208    }
209
210    public function getRestrictions( ?array $data ): ?MWRestrictions {
211        if ( $data && isset( $data['restrictions'] ) && is_string( $data['restrictions'] ) ) {
212            try {
213                return MWRestrictions::newFromJson( $data['restrictions'] );
214            } catch ( \InvalidArgumentException $e ) {
215                $this->logger->warning( __METHOD__ . ': Failed to parse restrictions: {restrictions}', [
216                    'restrictions' => $data['restrictions']
217                ] );
218                return null;
219            }
220        }
221        return null;
222    }
223}