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