Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
60.66% |
37 / 61 |
|
33.33% |
3 / 9 |
CRAP | |
0.00% |
0 / 1 |
SharedDomainHookHandler | |
60.66% |
37 / 61 |
|
33.33% |
3 / 9 |
99.32 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
onSetupAfterCache | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
onGetUserPermissionsErrors | |
44.44% |
4 / 9 |
|
0.00% |
0 / 1 |
9.29 | |||
onApiCheckCanExecute | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
onBeforePageDisplay | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
onResourceLoaderModifyEmbeddedSourceUrls | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
onAuthManagerFilterProviders | |
72.73% |
8 / 11 |
|
0.00% |
0 / 1 |
6.73 | |||
onAuthManagerVerifyAuthentication | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
onGetLocalURL | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
7.39 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\CentralAuth\Hooks\Handlers; |
4 | |
5 | use LogicException; |
6 | use MediaWiki\Api\Hook\ApiCheckCanExecuteHook; |
7 | use MediaWiki\Auth\AuthenticationResponse; |
8 | use MediaWiki\Auth\AuthManager; |
9 | use MediaWiki\Auth\Hook\AuthManagerFilterProvidersHook; |
10 | use MediaWiki\Auth\Hook\AuthManagerVerifyAuthenticationHook; |
11 | use MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider; |
12 | use MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider; |
13 | use MediaWiki\Context\RequestContext; |
14 | use MediaWiki\Extension\CentralAuth\CentralAuthRedirectingPrimaryAuthenticationProvider; |
15 | use MediaWiki\Extension\CentralAuth\FilteredRequestTracker; |
16 | use MediaWiki\Extension\CentralAuth\SharedDomainUtils; |
17 | use MediaWiki\Hook\GetLocalURLHook; |
18 | use MediaWiki\Hook\SetupAfterCacheHook; |
19 | use MediaWiki\Output\Hook\BeforePageDisplayHook; |
20 | use MediaWiki\Permissions\Hook\GetUserPermissionsErrorsHook; |
21 | use MediaWiki\ResourceLoader\Hook\ResourceLoaderModifyEmbeddedSourceUrlsHook; |
22 | use MediaWiki\User\UserIdentity; |
23 | use MediaWiki\Utils\UrlUtils; |
24 | use MediaWiki\WikiMap\WikiMap; |
25 | use MobileContext; |
26 | use MWExceptionHandler; |
27 | use Wikimedia\NormalizedException\NormalizedException; |
28 | |
29 | /** |
30 | * Ensure that the shared domain cannot be used for anything that is unrelated to its purpose. |
31 | */ |
32 | class SharedDomainHookHandler implements |
33 | ApiCheckCanExecuteHook, |
34 | AuthManagerFilterProvidersHook, |
35 | AuthManagerVerifyAuthenticationHook, |
36 | BeforePageDisplayHook, |
37 | GetLocalURLHook, |
38 | GetUserPermissionsErrorsHook, |
39 | ResourceLoaderModifyEmbeddedSourceUrlsHook, |
40 | SetupAfterCacheHook |
41 | { |
42 | |
43 | // Allowlists of things a user can do on the shared domain. |
44 | // FIXME these should be configurable and/or come from extension attributes |
45 | // 'static' is WMF's custom static.php entry point, serving some files on the shared domain (T374286) |
46 | private const ALLOWED_ENTRY_POINTS = [ 'index', 'api', 'static', 'cli' ]; |
47 | private const ALLOWED_SPECIAL_PAGES = [ 'Userlogin', 'Userlogout', 'CreateAccount', |
48 | 'PasswordReset', 'Captcha' ]; |
49 | private const ALLOWED_API_MODULES = [ |
50 | // needed for allowing any query API, even if we only want meta modules; it can be |
51 | // used to check page existence (which is unwanted functionality on the shared domain), |
52 | // which is unfortunate but permissions will still be checked, so it's not a risk. |
53 | 'query', |
54 | // allow login/signup directly via the API + help for those APIs |
55 | 'clientlogin', 'createaccount', 'authmanagerinfo', 'paraminfo', 'help', |
56 | // APIs used during web login |
57 | 'validatepassword', 'userinfo', 'webauthn', 'fancycaptchareload', |
58 | // generic meta APIs, there's a good chance something somewhere will use them |
59 | 'siteinfo', 'globaluserinfo', 'tokens', |
60 | ]; |
61 | |
62 | // List of authentication providers which should be skipped on the local login page in |
63 | // SUL3 mode, because they will be done on the shared domain instead. |
64 | // This is somewhat fragile, e.g. in case of class renamespacing. We inherit that from |
65 | // AuthManager and can't do much about it. It fails in the safe direction, though - on |
66 | // provider key mismatch there will be unnecessary extra checks. |
67 | private const DISALLOWED_LOCAL_PROVIDERS = [ |
68 | // FIXME what about preauth providers like AbuseFilter which don't generate a form |
69 | // but might prevent login and then the user ends up on a confusing login page? |
70 | // FIXME what about providers (if any) which generate a form but also do something else? |
71 | 'preauth' => [ |
72 | 'CaptchaPreAuthenticationProvider', |
73 | ], |
74 | 'primaryauth' => [ |
75 | TemporaryPasswordPrimaryAuthenticationProvider::class, |
76 | LocalPasswordPrimaryAuthenticationProvider::class, |
77 | ], |
78 | 'secondaryauth' => [ |
79 | 'OATHSecondaryAuthenticationProvider', |
80 | ], |
81 | ]; |
82 | |
83 | private UrlUtils $urlUtils; |
84 | private FilteredRequestTracker $filteredRequestTracker; |
85 | private SharedDomainUtils $sharedDomainUtils; |
86 | private ?MobileContext $mobileContext; |
87 | |
88 | public function __construct( |
89 | UrlUtils $urlUtils, |
90 | FilteredRequestTracker $filteredRequestTracker, |
91 | SharedDomainUtils $sharedDomainUtils, |
92 | ?MobileContext $mobileContext = null |
93 | ) { |
94 | $this->urlUtils = $urlUtils; |
95 | $this->filteredRequestTracker = $filteredRequestTracker; |
96 | $this->sharedDomainUtils = $sharedDomainUtils; |
97 | $this->mobileContext = $mobileContext; |
98 | } |
99 | |
100 | /** @inheritDoc */ |
101 | public function onSetupAfterCache() { |
102 | if ( $this->sharedDomainUtils->shouldRestrictCurrentDomain() ) { |
103 | // FIXME The REST API does not provide a hook for disabling APIs. No rest APIs |
104 | // should be needed for login and signup so we can just throw unconditionally, |
105 | // but this should be improved in the future. |
106 | // FIXME should not log a production error |
107 | if ( !in_array( MW_ENTRY_POINT, self::ALLOWED_ENTRY_POINTS, true ) ) { |
108 | throw new \RuntimeException( |
109 | MW_ENTRY_POINT . ' endpoint is not allowed on the shared domain' |
110 | ); |
111 | } |
112 | } |
113 | } |
114 | |
115 | /** @inheritDoc */ |
116 | public function onGetUserPermissionsErrors( $title, $user, $action, &$result ) { |
117 | if ( $this->sharedDomainUtils->shouldRestrictCurrentDomain() ) { |
118 | if ( !$title->isSpecialPage() ) { |
119 | $result = wfMessage( 'badaccess-group0' ); |
120 | return false; |
121 | } |
122 | foreach ( self::ALLOWED_SPECIAL_PAGES as $name ) { |
123 | if ( $title->isSpecial( $name ) ) { |
124 | return true; |
125 | } |
126 | } |
127 | $result = wfMessage( 'badaccess-group0' ); |
128 | return false; |
129 | } |
130 | } |
131 | |
132 | /** @inheritDoc */ |
133 | public function onApiCheckCanExecute( $module, $user, &$message ) { |
134 | if ( $this->sharedDomainUtils->shouldRestrictCurrentDomain() ) { |
135 | if ( !in_array( $module->getModuleName(), self::ALLOWED_API_MODULES ) ) { |
136 | $message = [ 'apierror-moduledisabled', $module->getModuleName() ]; |
137 | return false; |
138 | } |
139 | } |
140 | } |
141 | |
142 | /** @inheritDoc */ |
143 | public function onBeforePageDisplay( $out, $skin ): void { |
144 | if ( $this->sharedDomainUtils->shouldRestrictCurrentDomain() ) { |
145 | $out->disallowUserJs(); |
146 | } |
147 | } |
148 | |
149 | /** |
150 | * @inheritDoc |
151 | * @phan-param array{local:string} $urls |
152 | */ |
153 | public function onResourceLoaderModifyEmbeddedSourceUrls( array &$urls ): void { |
154 | $local = $urls['local']; |
155 | $local = $this->urlUtils->expand( $local, PROTO_CURRENT ); |
156 | // reassure Phan that expand() won't return null |
157 | '@phan-var string $local'; |
158 | if ( $this->mobileContext && $this->mobileContext->usingMobileDomain() ) { |
159 | $local = $this->mobileContext->getMobileUrl( $local ); |
160 | } |
161 | $urls['local'] = $local; |
162 | } |
163 | |
164 | /** |
165 | * If we are not on the shared domain and SUL3 is enabled, remove some authentication |
166 | * providers. They will run after the redirect on shared domain, so it's not necessary to |
167 | * run them locally, and on the local domain they would generate a login form, and we |
168 | * don't want that. |
169 | * @inheritDoc |
170 | * @note |
171 | */ |
172 | public function onAuthManagerFilterProviders( array &$providers ): void { |
173 | $request = RequestContext::getMain()->getRequest(); |
174 | if ( $this->sharedDomainUtils->isSul3Enabled( $request ) |
175 | && !$this->sharedDomainUtils->isSharedDomain() |
176 | ) { |
177 | // We'll rely on CentralAuthSharedDomainPreAuthenticationProvider to make sure filtering does not |
178 | // happen at the wrong time so make sure it's in place. |
179 | if ( !isset( $providers['preauth']['CentralAuthSharedDomainPreAuthenticationProvider'] ) ) { |
180 | throw new LogicException( |
181 | 'CentralAuthSharedDomainPreAuthenticationProvider not found during SUL3 login' |
182 | ); |
183 | } |
184 | |
185 | foreach ( self::DISALLOWED_LOCAL_PROVIDERS as $stage => $disallowedProviders ) { |
186 | foreach ( $disallowedProviders as $disallowedProvider ) { |
187 | unset( $providers[$stage][$disallowedProvider] ); |
188 | } |
189 | } |
190 | // This is security-critical code. If these providers are removed but some |
191 | // non-redirect-based login is still possible, or the providers are erroneously |
192 | // removed on the shared domain as well, that would circumvent important security |
193 | // checks. To prevent mistakes, we sync with the behavior of the |
194 | // AuthManagerVerifyAuthentication hook. |
195 | $this->filteredRequestTracker->markRequestAsFiltered( $request ); |
196 | } |
197 | } |
198 | |
199 | /** @inheritDoc */ |
200 | public function onAuthManagerVerifyAuthentication( |
201 | ?UserIdentity $user, |
202 | AuthenticationResponse &$response, |
203 | AuthManager $authManager, |
204 | array $info |
205 | ) { |
206 | if ( $this->filteredRequestTracker->isCurrentAuthenticationFlowFiltered( $authManager ) |
207 | && $info['primaryId'] !== CentralAuthRedirectingPrimaryAuthenticationProvider::class |
208 | ) { |
209 | // If providers were filtered, but then authentication wasn't handled by redirecting, |
210 | // report and interrupt. |
211 | MWExceptionHandler::logException( new NormalizedException( |
212 | 'Providers were filtered but redirecting provider was not the primary', |
213 | [ |
214 | 'user' => $user->getName(), |
215 | 'result' => $response->status, |
216 | ] + $info |
217 | ) ); |
218 | $response = AuthenticationResponse::newFail( wfMessage( 'internalerror' ) ); |
219 | return false; |
220 | } |
221 | } |
222 | |
223 | /** @inheritDoc */ |
224 | public function onGetLocalURL( $title, &$url, $query ) { |
225 | if ( $this->sharedDomainUtils->shouldRestrictCurrentDomain() ) { |
226 | // Only allow links to auth-related special pages on the shared domain. |
227 | // Point all other links to the normal wiki domain. |
228 | foreach ( self::ALLOWED_SPECIAL_PAGES as $name ) { |
229 | if ( $title->isSpecial( $name ) ) { |
230 | return; |
231 | } |
232 | } |
233 | if ( $title->getInterwiki() !== '' ) { |
234 | return; |
235 | } |
236 | |
237 | // WikiMap entry for the current wiki points to the normal wiki domain, |
238 | // even when $wgServer etc. were overridden for the shared domain. |
239 | $currentWiki = WikiMap::getWiki( WikiMap::getCurrentWikiId() ); |
240 | $url = wfAppendQuery( $currentWiki->getCanonicalUrl( $title->getPrefixedText() ), $query ); |
241 | |
242 | if ( $this->mobileContext && $this->mobileContext->shouldDisplayMobileView() ) { |
243 | $url = $this->mobileContext->getMobileUrl( $url ); |
244 | } |
245 | } |
246 | } |
247 | } |