Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
40.32% covered (danger)
40.32%
25 / 62
20.00% covered (danger)
20.00%
2 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SharedDomainUtils
40.32% covered (danger)
40.32%
25 / 62
20.00% covered (danger)
20.00%
2 / 10
221.28
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isSharedDomain
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 shouldRestrictCurrentDomain
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isSul3Enabled
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 assertSul3Enabled
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 assertIsSharedDomain
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 assertIsNotSharedDomain
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getUrlForSharedDomainAction
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 shouldUseMobile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 makeUrlDeviceCompliant
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\Extension\CentralAuth;
4
5use MediaWiki\Config\Config;
6use MediaWiki\Extension\CentralAuth\Config\CAMainConfigNames;
7use MediaWiki\Extension\CentralAuth\Hooks\Handlers\SharedDomainHookHandler;
8use MediaWiki\MainConfigNames;
9use MediaWiki\Request\WebRequest;
10use MediaWiki\Title\TitleFactory;
11use MobileContext;
12use RuntimeException;
13use Wikimedia\Assert\Assert;
14
15/**
16 * Utilities for handling the shared domain name used for SUL3 login.
17 * This class is kept lightweight, so it can be used in early hooks.
18 */
19class SharedDomainUtils {
20
21    private const SUL3_COOKIE_FLAG = 'sul3OptIn';
22    private Config $config;
23    private TitleFactory $titleFactory;
24    private ?bool $isSharedDomain = null;
25    private ?MobileContext $mobileContext;
26    private bool $isApiRequest;
27
28    public function __construct(
29        Config $config,
30        TitleFactory $titleFactory,
31        ?MobileContext $mobileContext,
32        bool $isApiRequest
33    ) {
34        $this->config = $config;
35        $this->titleFactory = $titleFactory;
36        $this->mobileContext = $mobileContext;
37        $this->isApiRequest = $isApiRequest;
38    }
39
40    /**
41     * Whether the current request is to the shared domain used for SUL3 login.
42     *
43     * This assumes:
44     * - $wgCentralAuthSharedDomainPrefix contains the shared domain.
45     * - $wgCanonicalServer is set in site configuration to the current domain
46     *   (instead of the actual canonical domain) for requests to the shared domain.
47     *
48     * @return bool
49     */
50    public function isSharedDomain(): bool {
51        if ( $this->isSharedDomain === null ) {
52            $sharedDomainPrefix = $this->config->get( CAMainConfigNames::CentralAuthSharedDomainPrefix );
53            if ( !$sharedDomainPrefix ) {
54                $this->isSharedDomain = false;
55            } else {
56                $sharedDomain = parse_url( $sharedDomainPrefix, PHP_URL_HOST );
57                $currentDomain = parse_url(
58                    $this->config->get( MainConfigNames::CanonicalServer ), PHP_URL_HOST
59                );
60                $this->isSharedDomain = $sharedDomain && $currentDomain === $sharedDomain;
61            }
62        }
63        return $this->isSharedDomain;
64    }
65
66    /**
67     * Whether the current request must deny non-auth actions.
68     *
69     * If $wgCentralAuthRestrictSharedDomain is enabled, then requests to the "fake"
70     * shared domain within $wgCentralAuthSharedDomainPrefix must only be for authentication
71     * purposes. All non-authentication-related actions should be prevented.
72     *
73     * SUL3 login supports both using a dedicated login wiki for the domain where the central
74     * session cookies are stored, and a shared domain which serve any wiki (from a virtual
75     * sub directory). In the latter case, we want to prevent non-authentication actions
76     * to prevent complications like cache splits. This flag differentiates between the two
77     * setups.
78     *
79     * @return bool
80     * @see SharedDomainHookHandler
81     */
82    public function shouldRestrictCurrentDomain(): bool {
83        return $this->isSharedDomain() && $this->config->get( CAMainConfigNames::CentralAuthRestrictSharedDomain );
84    }
85
86    /**
87     * Whether SUL3 mode is enabled on this wiki and/or this request.
88     *
89     * In order to facilitate testing of SUL3 migration, this method
90     * provides mechanisms for testing the SUL3 feature including a
91     * cookie-based feature flag.
92     *
93     * SUL3 mode is enabled if any of the following conditions is true:
94     * - $wgCentralAuthEnableSul3 contains 'always'
95     * - $wgCentralAuthEnableSul3 contains 'cookie' and there is a
96     *   cookie named 'sul3OptIn' with the value '1'
97     * - $wgCentralAuthEnableSul3 contains 'query-flag' and the URL has
98     *   a query parameter 'usesul3' with the value "1". The value "0"
99     *   means switch off SUL3 mode.
100     *
101     * @param WebRequest $request
102     * @return bool
103     */
104    public function isSul3Enabled( WebRequest $request ): bool {
105        // T379816: `clientlogin` API should still work in SUL3 mode as if we're
106        //    in SUL2 mode regardless of whether SUL3 is enabled or not. This provider
107        //    should operate the same in both modes when the request is an API request.
108        if ( $this->isApiRequest && !$this->isSharedDomain() ) {
109            return false;
110        }
111
112        $sul3Config = $this->config->get( CAMainConfigNames::CentralAuthEnableSul3 );
113
114        if ( in_array( 'query-flag', $sul3Config, true )
115            && $request->getCheck( 'usesul3' )
116        ) {
117            return $request->getFuzzyBool( 'usesul3' );
118        } elseif ( in_array( 'cookie', $sul3Config, true )
119            && $request->getCookie( self::SUL3_COOKIE_FLAG, '' ) === '1'
120        ) {
121            return true;
122        } elseif ( in_array( 'always', $sul3Config, true ) ) {
123            return true;
124        } else {
125            return false;
126        }
127    }
128
129    /**
130     * Assert that the SUL3 mode is allowed.
131     *
132     * @param WebRequest $request
133     * @return void
134     */
135    public function assertSul3Enabled( WebRequest $request ) {
136        Assert::precondition(
137            $this->isSul3Enabled( $request ),
138            'SUL3 is not enabled. Set $wgCentralAuthEnableSul3 to boolean true.'
139        );
140    }
141
142    /**
143     * Assert that we're on the shared login domain.
144     *
145     * @return void
146     */
147    public function assertIsSharedDomain() {
148        Assert::precondition(
149            $this->isSharedDomain(),
150            'This action is not allowed because the domain is not the shared login domain.'
151        );
152    }
153
154    /**
155     * Assert that we're not on the shared login domain.
156     *
157     * @return void
158     */
159    public function assertIsNotSharedDomain() {
160        Assert::precondition(
161            !( $this->isSharedDomain() ),
162            'This action is not allowed because the domain is not the shared login domain.'
163        );
164    }
165
166    /**
167     * Get the login/signup URL on the shared login domain wiki.
168     *
169     * @param string $action 'login' or 'signup' action
170     * @param WebRequest|null $request There could be more to look at
171     *    in the request like if we're coming from a campaign link.
172     *
173     * @return string
174     */
175    public function getUrlForSharedDomainAction( string $action, ?WebRequest $request = null ): string {
176        switch ( $action ) {
177            case 'login':
178                $localUrl = $this->titleFactory->newFromText( 'Special:UserLogin' )->getLocalURL();
179                break;
180            case 'signup':
181                $localUrl = $this->titleFactory->newFromText( 'Special:CreateAccount' )->getLocalURL();
182                break;
183            default:
184                throw new RuntimeException( 'Unknown action: ' . $action );
185        }
186
187        $url = $this->makeUrlDeviceCompliant(
188            $this->config->get( CAMainConfigNames::CentralAuthSharedDomainPrefix ) . $localUrl
189        );
190
191        return wfAppendQuery( $url, [
192            // TODO: Fix T369467
193            'returnto' => 'Main_Page',
194            'usesul3' => '1',
195            'campaign' => $request ? $request->getRawVal( 'campaign' ) : null,
196        ] );
197    }
198
199    /**
200     * @return bool True if on mobile device
201     */
202    public function shouldUseMobile(): bool {
203        return $this->mobileContext && $this->mobileContext->shouldDisplayMobileView();
204    }
205
206    /**
207     * Check the URL and apply transformation based on the device
208     * that is currently looking at it. If mobile, apply the mobile
209     * transformation to the URL so we view the correct rendering.
210     *
211     * Get the mobile domain (m.) version of the URL if available
212     * configured (in that WMF is currently configured to have separate
213     * domain for mobile and desktop versions of sites) and we want that
214     * instead of just appending a `useformat` query parameter, if the
215     * domain is a mobile domain, just return it but if it's not, we
216     * detect that and append a `useformat` query param..
217     *
218     * @param string $url
219     *
220     * @return string
221     */
222    public function makeUrlDeviceCompliant( string $url ): string {
223        // Assume either all or none of the wikis in the farm have MobileFrontend
224        if ( !$this->mobileContext ) {
225            return $url;
226        }
227
228        $mobileUrl = $this->mobileContext->getMobileUrl( $url );
229        // Some wikis don't have separate mobile and desktop versions at different URLs,
230        // in which case getMobileUrl() is a no-op.
231        $hasMobileUrl = ( $mobileUrl !== $url );
232
233        if ( $this->mobileContext->shouldDisplayMobileView() ) {
234            return $hasMobileUrl ? $mobileUrl : wfAppendQuery( $url, [ 'useformat' => 'mobile' ] );
235        } else {
236            // useformat=desktop is the default, and so we don't really need to set it,
237            // but we want to consider the possibility that the user has previously used
238            // the central domain and set it to mobile mode via a cookie. In that case,
239            // we want to prioritize the consistency of the current mode over that setting.
240            return $hasMobileUrl ? $url : wfAppendQuery( $url, [ 'useformat' => 'desktop' ] );
241        }
242    }
243
244}