Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 80 |
|
0.00% |
0 / 3 |
CRAP | |
0.00% |
0 / 1 |
SpecialPageBeforeExecuteHookHandler | |
0.00% |
0 / 80 |
|
0.00% |
0 / 3 |
870 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
onSpecialPageBeforeExecute | |
0.00% |
0 / 69 |
|
0.00% |
0 / 1 |
702 | |||
log | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\CentralAuth\Hooks\Handlers; |
4 | |
5 | use MediaWiki\Auth\AuthManager; |
6 | use MediaWiki\Config\Config; |
7 | use MediaWiki\Extension\CentralAuth\CentralAuthHooks; |
8 | use MediaWiki\Extension\CentralAuth\CentralAuthTokenManager; |
9 | use MediaWiki\Extension\CentralAuth\Config\CAMainConfigNames; |
10 | use MediaWiki\Extension\CentralAuth\SharedDomainUtils; |
11 | use MediaWiki\Logger\LoggerFactory; |
12 | use MediaWiki\SpecialPage\Hook\SpecialPageBeforeExecuteHook; |
13 | use MediaWiki\SpecialPage\SpecialPage; |
14 | use MediaWiki\Title\Title; |
15 | use MediaWiki\WikiMap\WikiMap; |
16 | use MobileContext; |
17 | use Wikimedia\LightweightObjectStore\ExpirationAwareness; |
18 | |
19 | /** |
20 | * Triggers a server-side (top-level) central autologin attempt on Special:Userlogin. |
21 | */ |
22 | class 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 | } |