Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
CentralAuthRedirectingPrimaryAuthenticationProvider
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 11
992
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getAuthenticationRequests
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
42
 beginPrimaryAuthentication
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
30
 continuePrimaryAuthentication
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
132
 testUserCanAuthenticate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 providerNormalizeUsername
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 testUserExists
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 providerAllowsAuthenticationDataChange
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 providerChangeAuthenticationData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 accountCreationType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 beginPrimaryAccountCreation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\CentralAuth;
4
5use LogicException;
6use MediaWiki\Auth\AbstractPrimaryAuthenticationProvider;
7use MediaWiki\Auth\AuthenticationRequest;
8use MediaWiki\Auth\AuthenticationResponse;
9use MediaWiki\Auth\AuthManager;
10use MediaWiki\Extension\CentralAuth\Hooks\Handlers\RedirectingLoginHookHandler;
11use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
12use MediaWiki\Logger\LoggerFactory;
13use MediaWiki\MainConfigNames;
14use MediaWiki\User\UserNameUtils;
15use MobileContext;
16use MWCryptRand;
17use RuntimeException;
18use StatusValue;
19use Wikimedia\Rdbms\IDBAccessObject;
20
21/**
22 * Redirect-based provider which sends the user to another domain, assumed to be
23 * served by the same wiki farm, to log in, and expects to receive the result of
24 * that authentication process when the user returns.
25 *
26 * @see RedirectingLoginHookHandler
27 */
28class CentralAuthRedirectingPrimaryAuthenticationProvider
29    extends AbstractPrimaryAuthenticationProvider
30{
31
32    public const NON_LOGIN_WIKI_BUTTONREQUEST_NAME = 'nonloginwiki';
33    public const START_TOKEN_KEY_PREFIX = 'centralauth-sul3-start';
34    public const COMPLETE_TOKEN_KEY_PREFIX = 'centralauth-sul3-complete';
35
36    private CentralAuthTokenManager $tokenManager;
37    private SharedDomainUtils $sharedDomainUtils;
38    private ?MobileContext $mobileContext;
39
40    public function __construct(
41        CentralAuthTokenManager $tokenManager,
42        SharedDomainUtils $sharedDomainUtils,
43        ?MobileContext $mobileContext
44    ) {
45        $this->tokenManager = $tokenManager;
46        $this->sharedDomainUtils = $sharedDomainUtils;
47        $this->mobileContext = $mobileContext;
48    }
49
50    /** @inheritDoc */
51    public function getAuthenticationRequests( $action, array $options ) {
52        if ( $this->sharedDomainUtils->isSul3Enabled( $this->manager->getRequest() )
53            && !$this->sharedDomainUtils->isSharedDomain()
54        ) {
55            switch ( $action ) {
56                case AuthManager::ACTION_LOGIN:
57                case AuthManager::ACTION_CREATE:
58                    return [ new CentralAuthRedirectingAuthenticationRequest() ];
59                default:
60                    return [];
61            }
62        }
63        return [];
64    }
65
66    /**
67     * Store a random secret in the session and redirect the user to the central login wiki,
68     * passing the secret and the return URL via the token store. The secret will be used on
69     * return to prevent a session fixation attack.
70     *
71     * @inheritDoc
72     */
73    public function beginPrimaryAuthentication( array $reqs ) {
74        $req = CentralAuthRedirectingAuthenticationRequest::getRequestByName(
75            $reqs,
76            self::NON_LOGIN_WIKI_BUTTONREQUEST_NAME
77        );
78
79        if ( !$req ) {
80            return AuthenticationResponse::newAbstain();
81        }
82
83        $this->sharedDomainUtils->assertSul3Enabled( $this->manager->getRequest() );
84        $this->sharedDomainUtils->assertIsNotSharedDomain();
85
86        $secret = MWCryptRand::generateHex( 32 );
87        $this->manager->setAuthenticationSessionData( 'CentralAuth:sul3-login:pending', [
88            'secret' => $secret,
89        ] );
90
91        // For the most part, MediaWiki is not aware of mobile domains and uses the standard
92        // domain in all URLs it generates; they must be adjusted manually.
93        $returnToUrl = $req->returnToUrl;
94        if ( $this->mobileContext && $this->mobileContext->usingMobileDomain() ) {
95            $returnToUrl = $this->mobileContext->getMobileUrl( $returnToUrl );
96        }
97
98        $data = [
99            'secret' => $secret,
100            'returnUrl' => $returnToUrl,
101        ];
102        // ObjectCacheSessionExpiry will limit how long the local login process, which relies
103        // on the session, can be finished. Sync the expiry of this token (which will be used
104        // when central login ends) with that.
105        $expiry = $this->config->get( MainConfigNames::ObjectCacheSessionExpiry );
106        $token = $this->tokenManager->tokenize(
107            $data, self::START_TOKEN_KEY_PREFIX, [ 'expiry' => $expiry ]
108        );
109
110        if ( $this->manager->getRequest()->getRawVal( 'sul3-action' ) === 'signup' ) {
111            $sharedDomainUrl = $this->sharedDomainUtils->getUrlForSharedDomainAction(
112                'signup',
113                $this->manager->getRequest()
114            );
115            $url = wfAppendQuery( $sharedDomainUrl, [ 'centralauthLoginToken' => $token ] );
116        } else {
117            $url = wfAppendQuery(
118                $this->sharedDomainUtils->getUrlForSharedDomainAction( 'login' ),
119                [ 'centralauthLoginToken' => $token ]
120            );
121        }
122
123        return AuthenticationResponse::newRedirect( [ new CentralAuthReturnRequest() ], $url );
124    }
125
126    /**
127     * Verify the secret and log the user in.
128     *
129     * @inheritDoc
130     */
131    public function continuePrimaryAuthentication( array $reqs ) {
132        $this->sharedDomainUtils->assertSul3Enabled( $this->manager->getRequest() );
133        $this->sharedDomainUtils->assertIsNotSharedDomain();
134
135        $req = AuthenticationRequest::getRequestByClass(
136            $reqs, CentralAuthReturnRequest::class
137        );
138
139        if ( !$req ) {
140            throw new LogicException( 'CentralAuthReturnRequest not found' );
141        }
142
143        $data = $this->tokenManager->detokenizeAndDelete(
144            $req->centralauthLoginToken, self::COMPLETE_TOKEN_KEY_PREFIX
145        );
146        $sessionData = $this->manager->getAuthenticationSessionData( 'CentralAuth:sul3-login:pending' );
147        if ( $data === false || $sessionData === false ) {
148            // TODO this will happen if the user spends too much time on the login form.
149            //   We should make sure the message is user-friendly.
150            return AuthenticationResponse::newFail( wfMessage( 'centralauth-error-badtoken' ) );
151        }
152        foreach ( [ 'secret', 'username' ] as $key ) {
153            if ( !isset( $data[$key] ) ) {
154                throw new LogicException( "$key not found in return data" );
155            }
156        }
157        if ( !isset( $sessionData['secret'] ) ) {
158            throw new LogicException( 'Secret not found in session data' );
159        }
160
161        // Only the user who started the authentication process can have the secret in their local
162        // session. There is no way to guarantee that the person entering their credentials on the
163        // login form on the shared domain is the same; if an attacker initiates a login flow,
164        // tricks a victim into visiting the redirect URL returned by beginPrimaryAuthentication(),
165        // and then is somehow able to obtain the URL the victim would be redirected back to after
166        // submitting the login form, they would get logged in as the victim locally. But there is
167        // no way to do that without fundamentally compromising browser, site or network security.
168        if ( !$data['secret'] || $data['secret'] !== $sessionData['secret'] ) {
169            LoggerFactory::getInstance( 'security' )->error( __CLASS__ . ': Secret mismatch',
170                [
171                    'username' => $data['username'],
172                ]
173            );
174            return AuthenticationResponse::newFail( wfMessage( 'centralauth-error-badtoken' ) );
175        }
176
177        $centralUser = CentralAuthUser::getInstanceByName( $data['username'] );
178        if ( $centralUser->getId() !== $data['userId'] ) {
179            // Extremely unlikely but technically possible with global rename race conditions
180            throw new RuntimeException( 'User ID mismatch' );
181        }
182
183        // Refresh the local wiki's "remember me" state based on the
184        // corresponding state from the central domain.
185        if ( $data['rememberMe'] ) {
186            $this->manager->setAuthenticationSessionData( AuthManager::REMEMBER_ME, true );
187        }
188
189        return AuthenticationResponse::newPass( $data['username'] );
190    }
191
192    /** @inheritDoc */
193    public function testUserCanAuthenticate( $username ) {
194        return false;
195    }
196
197    /** @inheritDoc */
198    public function providerNormalizeUsername( $username ) {
199        return null;
200    }
201
202    /** @inheritDoc */
203    public function testUserExists( $username, $flags = IDBAccessObject::READ_NORMAL ) {
204        $username = $this->userNameUtils->getCanonical( $username, UserNameUtils::RIGOR_USABLE );
205        if ( $username === false ) {
206            return false;
207        }
208
209        $centralUser = CentralAuthUser::getInstanceByName( $username );
210        return $centralUser->exists();
211    }
212
213    /** @inheritDoc */
214    public function providerAllowsAuthenticationDataChange( AuthenticationRequest $req, $checkData = true ) {
215        return StatusValue::newGood( 'ignored' );
216    }
217
218    /** @inheritDoc */
219    public function providerChangeAuthenticationData( AuthenticationRequest $req ) {
220    }
221
222    /** @inheritDoc */
223    public function accountCreationType() {
224        return self::TYPE_NONE;
225    }
226
227    /** @inheritDoc */
228    public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) {
229        return AuthenticationResponse::newAbstain();
230    }
231}