Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialPageBeforeExecuteHookHandler
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 3
870
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 onSpecialPageBeforeExecute
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 1
702
 log
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\CentralAuth\Hooks\Handlers;
4
5use MediaWiki\Auth\AuthManager;
6use MediaWiki\Config\Config;
7use MediaWiki\Extension\CentralAuth\CentralAuthHooks;
8use MediaWiki\Extension\CentralAuth\CentralAuthTokenManager;
9use MediaWiki\Extension\CentralAuth\Config\CAMainConfigNames;
10use MediaWiki\Extension\CentralAuth\SharedDomainUtils;
11use MediaWiki\Logger\LoggerFactory;
12use MediaWiki\SpecialPage\Hook\SpecialPageBeforeExecuteHook;
13use MediaWiki\SpecialPage\SpecialPage;
14use MediaWiki\Title\Title;
15use MediaWiki\WikiMap\WikiMap;
16use MobileContext;
17use Wikimedia\LightweightObjectStore\ExpirationAwareness;
18
19/**
20 * Triggers a server-side (top-level) central autologin attempt on Special:Userlogin.
21 */
22class SpecialPageBeforeExecuteHookHandler implements SpecialPageBeforeExecuteHook {
23
24    /**
25     * Name of the cookie that represents that top-level auto-login has been attempted.
26     * It is set before the initiation of autologin, cleared on success (or via expiry),
27     * and prevents further (or concurrent) attempts.
28     * See also ext.centralauth.centralautologin.clearcookie.js.
29     */
30    public const AUTOLOGIN_TRIED_COOKIE = 'CentralAuthAnonTopLevel';
31
32    /**
33     * Query parameter that marks that we have just returned from a top-level autologin attempt.
34     * Used to prevent redirect loops. Normally the cookie would do that, but the client might
35     * not record the cookie for some reason.
36     */
37    private const AUTOLOGIN_TRIED_QUERY_PARAM = 'centralAuthAutologinTried';
38
39    /** Query parameter used to return error messages from SpecialCentralAutoLogin. */
40    public const AUTOLOGIN_ERROR_QUERY_PARAM = 'centralAuthError';
41
42    private AuthManager $authManager;
43    private Config $config;
44    private CentralAuthTokenManager $tokenManager;
45    private SharedDomainUtils $sharedDomainUtils;
46
47    /**
48     * @param AuthManager $authManager
49     * @param Config $config
50     * @param CentralAuthTokenManager $tokenManager
51     * @param SharedDomainUtils $sharedDomainUtils
52     */
53    public function __construct(
54        AuthManager $authManager,
55        Config $config,
56        CentralAuthTokenManager $tokenManager,
57        SharedDomainUtils $sharedDomainUtils
58    ) {
59        $this->authManager = $authManager;
60        $this->config = $config;
61        $this->tokenManager = $tokenManager;
62        $this->sharedDomainUtils = $sharedDomainUtils;
63    }
64
65    /**
66     * Triggers top-level central autologin attempt on Special:Userlogin, and handles the
67     * outcome of such an attempt at the end of the redirect chain.
68     *
69     * @param SpecialPage $special
70     * @param string|null $subPage
71     * @return bool
72     *
73     * @see SpecialCentralAutoLogin
74     */
75    public function onSpecialPageBeforeExecute( $special, $subPage ) {
76        $request = $special->getRequest();
77        $amKey = 'AuthManagerSpecialPage:return:' . $special->getName();
78
79        // In SUL3 mode, let's trigger a central mechanism for account creation.
80        if ( $special->getName() === 'CreateAccount'
81            && $this->sharedDomainUtils->isSul3Enabled( $request )
82            && !$this->sharedDomainUtils->isSharedDomain()
83        ) {
84            $this->sharedDomainUtils->assertSul3Enabled( $request );
85            $this->sharedDomainUtils->assertIsNotSharedDomain();
86
87            $localLoginUrl = SpecialPage::getTitleFor( 'Userlogin' )->getLocalURL();
88
89            $url = wfAppendQuery( $localLoginUrl, [ 'sul3-action' => 'signup' ] );
90            // If the account creation is coming from a campaign identifier,
91            // forward that as well in SUL3 mode.
92            $campaignParam = $request->getRawVal( 'campaign' );
93            if ( $campaignParam ) {
94                $url = wfAppendQuery( $url, [ 'campaign' => $campaignParam ] );
95            }
96
97            $special->getOutput()->redirect( $url );
98            return true;
99        }
100
101        // Only attempt top-level autologin if the user is about to log in, the login isn't
102        // already in progress, it is a normal login, and there is a central login wiki to use.
103        // This check will also pass if the user just finished (successfully or not) the autologin.
104        if ( $special->getName() !== 'Userlogin'
105            || $request->wasPosted()
106            || $subPage
107            // Deal with the edge case where AuthManagerSpecialPage::handleReturnBeforeExecute()
108            // redirects from Special:Userlogin/return, and the login session is the only thing
109            // that gives away that we are in the middle of a remote login flow.
110            || $this->authManager->getAuthenticationSessionData( $amKey )
111            // elevated-security reauthentication of already logged-in user
112            || $request->getBool( 'force' )
113            || $this->sharedDomainUtils->isSul3Enabled( $request )
114            || !$this->config->get( CAMainConfigNames::CentralAuthLoginWiki )
115            || $this->config->get( CAMainConfigNames::CentralAuthLoginWiki ) === WikiMap::getCurrentWikiId()
116        ) {
117            return true;
118        }
119
120        $isMobile = CentralAuthHooks::isMobileDomain();
121
122        // Do a top-level autologin if the user needs to log in. Use a cookie to prevent
123        // unnecessary autologin attempts if we already know they will fail, and a query parameter
124        // (to be set by SpecialCentralAutoLogin) to avoid an infinite loop even if we cannot set
125        // cookies for some reason.
126        if ( $special->getUser()->isAnon()
127             && !$request->getCookie( self::AUTOLOGIN_TRIED_COOKIE, '' )
128             && !$request->getCheck( self::AUTOLOGIN_TRIED_QUERY_PARAM )
129        ) {
130            $url = WikiMap::getForeignURL(
131                $this->config->get( CAMainConfigNames::CentralAuthLoginWiki ),
132                'Special:CentralAutoLogin/checkLoggedIn'
133            );
134            if ( $url === false ) {
135                // WikiMap misconfigured?
136                return true;
137            }
138
139            $this->log( 'Top-level autologin started', $special, $isMobile );
140
141            $request->response()->setCookie(
142                self::AUTOLOGIN_TRIED_COOKIE,
143                '1',
144                // use 1 day like anon-set.js
145                time() + ExpirationAwareness::TTL_DAY,
146                // match the behavior of the CentralAuthAnon cookie
147                [ 'prefix' => '', 'httpOnly' => false ]
148            );
149
150            $returnUrl = wfAppendQuery( $request->getFullRequestURL(), [
151                self::AUTOLOGIN_TRIED_QUERY_PARAM => 1,
152            ] );
153            if ( CentralAuthHooks::isMobileDomain() ) {
154                // WebRequest::getFullRequestURL() uses $wgServer, not the actual request
155                // domain, but we do want to preserve that
156                $returnUrl = MobileContext::singleton()->getMobileUrl( $returnUrl );
157            }
158            $returnUrlToken = $this->tokenManager->tokenize( $returnUrl, 'centralautologin-returnurl' );
159            $url = wfAppendQuery( $url, [
160                'type' => 'redirect',
161                'returnUrlToken' => $returnUrlToken,
162                'wikiid' => WikiMap::getCurrentWikiId(),
163                'mobile' => $isMobile ? 1 : null,
164            ] );
165            $special->getOutput()->redirect( $url );
166
167            return false;
168        }
169
170        // Clean up after successful autologin.
171        if ( $special->getUser()->isRegistered()
172            && $request->getCheck( self::AUTOLOGIN_TRIED_QUERY_PARAM )
173        ) {
174            $this->log( 'Top-level autologin succeeded', $special, $isMobile );
175
176            $request->response()->clearCookie( self::AUTOLOGIN_TRIED_COOKIE, [ 'prefix' => '' ] );
177            // If returnto is set, let SpecialUserlogin redirect the user. If it is not set,
178            // we would just show the login page, which would be confusing, so send the user away.
179            if ( $request->getCheck( 'returnto' ) ) {
180                return true;
181            } else {
182                $special->getOutput()->redirect( Title::newMainPage( $special->getContext() )->getLocalURL() );
183                return false;
184            }
185        }
186
187        // Log failed / prevented autologin.
188        if ( $special->getUser()->isAnon()
189            && $request->getCheck( self::AUTOLOGIN_TRIED_QUERY_PARAM )
190        ) {
191            $error = $request->getRawVal( self::AUTOLOGIN_ERROR_QUERY_PARAM ) ?? 'unknown error';
192            $this->log( "Top-level autologin failed: $error", $special, $isMobile );
193        } elseif ( $special->getUser()->isAnon()
194            && $request->getCookie( self::AUTOLOGIN_TRIED_COOKIE, '' )
195        ) {
196            $this->log( 'Top-level autologin prevented by cookie', $special, $isMobile );
197        }
198        // Else: the user is already logged in and manually visiting the login page, e.g.
199        // to log in as another user. Nothing to do.
200
201        return true;
202    }
203
204    private function log( string $message, SpecialPage $special, bool $isMobile ): void {
205        $request = $special->getRequest();
206        LoggerFactory::getInstance( 'CentralAuth' )->debug( $message, [
207            'userAgent' => $request->getHeader( 'User-Agent' ),
208            'isMobile' => $isMobile,
209            'username' => $special->getUser()->isRegistered() ? $special->getUser()->getName() : '',
210            'suggestedLoginUsername' => $request->getSession()->suggestLoginUsername(),
211        ] );
212    }
213
214}