AuthManagerSpecialPage.php 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767
  1. <?php
  2. use MediaWiki\Auth\AuthenticationRequest;
  3. use MediaWiki\Auth\AuthenticationResponse;
  4. use MediaWiki\Auth\AuthManager;
  5. use MediaWiki\Logger\LoggerFactory;
  6. use MediaWiki\Session\Token;
  7. /**
  8. * A special page subclass for authentication-related special pages. It generates a form from
  9. * a set of AuthenticationRequest objects, submits the result to AuthManager and
  10. * partially handles the response.
  11. */
  12. abstract class AuthManagerSpecialPage extends SpecialPage {
  13. /** @var string[] The list of actions this special page deals with. Subclasses should override
  14. * this. */
  15. protected static $allowedActions = [
  16. AuthManager::ACTION_LOGIN, AuthManager::ACTION_LOGIN_CONTINUE,
  17. AuthManager::ACTION_CREATE, AuthManager::ACTION_CREATE_CONTINUE,
  18. AuthManager::ACTION_LINK, AuthManager::ACTION_LINK_CONTINUE,
  19. AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK,
  20. ];
  21. /** @var array Customized messages */
  22. protected static $messages = [];
  23. /** @var string one of the AuthManager::ACTION_* constants. */
  24. protected $authAction;
  25. /** @var AuthenticationRequest[] */
  26. protected $authRequests;
  27. /** @var string Subpage of the special page. */
  28. protected $subPage;
  29. /** @var bool True if the current request is a result of returning from a redirect flow. */
  30. protected $isReturn;
  31. /** @var WebRequest|null If set, will be used instead of the real request. Used for redirection. */
  32. protected $savedRequest;
  33. /**
  34. * Change the form descriptor that determines how a field will look in the authentication form.
  35. * Called from fieldInfoToFormDescriptor().
  36. * @param AuthenticationRequest[] $requests
  37. * @param array $fieldInfo Field information array (union of all
  38. * AuthenticationRequest::getFieldInfo() responses).
  39. * @param array &$formDescriptor HTMLForm descriptor. The special key 'weight' can be set to
  40. * change the order of the fields.
  41. * @param string $action Authentication type (one of the AuthManager::ACTION_* constants)
  42. * @return bool
  43. */
  44. public function onAuthChangeFormFields(
  45. array $requests, array $fieldInfo, array &$formDescriptor, $action
  46. ) {
  47. return true;
  48. }
  49. protected function getLoginSecurityLevel() {
  50. return $this->getName();
  51. }
  52. public function getRequest() {
  53. return $this->savedRequest ?: $this->getContext()->getRequest();
  54. }
  55. /**
  56. * Override the POST data, GET data from the real request is preserved.
  57. *
  58. * Used to preserve POST data over a HTTP redirect.
  59. *
  60. * @param array $data
  61. * @param bool|null $wasPosted
  62. */
  63. protected function setRequest( array $data, $wasPosted = null ) {
  64. $request = $this->getContext()->getRequest();
  65. if ( $wasPosted === null ) {
  66. $wasPosted = $request->wasPosted();
  67. }
  68. $this->savedRequest = new DerivativeRequest( $request, $data + $request->getQueryValues(),
  69. $wasPosted );
  70. }
  71. protected function beforeExecute( $subPage ) {
  72. $this->getOutput()->disallowUserJs();
  73. return $this->handleReturnBeforeExecute( $subPage )
  74. && $this->handleReauthBeforeExecute( $subPage );
  75. }
  76. /**
  77. * Handle redirection from the /return subpage.
  78. *
  79. * This is used in the redirect flow where we need
  80. * to be able to process data that was sent via a GET request. We set the /return subpage as
  81. * the reentry point so we know we need to treat GET as POST, but we don't want to handle all
  82. * future GETs as POSTs so we need to normalize the URL. (Also we don't want to show any
  83. * received parameters around in the URL; they are ugly and might be sensitive.)
  84. *
  85. * Thus when on the /return subpage, we stash the request data in the session, redirect, then
  86. * use the session to detect that we have been redirected, recover the data and replace the
  87. * real WebRequest with a fake one that contains the saved data.
  88. *
  89. * @param string $subPage
  90. * @return bool False if execution should be stopped.
  91. */
  92. protected function handleReturnBeforeExecute( $subPage ) {
  93. $authManager = AuthManager::singleton();
  94. $key = 'AuthManagerSpecialPage:return:' . $this->getName();
  95. if ( $subPage === 'return' ) {
  96. $this->loadAuth( $subPage );
  97. $preservedParams = $this->getPreservedParams( false );
  98. // FIXME save POST values only from request
  99. $authData = array_diff_key( $this->getRequest()->getValues(),
  100. $preservedParams, [ 'title' => 1 ] );
  101. $authManager->setAuthenticationSessionData( $key, $authData );
  102. $url = $this->getPageTitle()->getFullURL( $preservedParams, false, PROTO_HTTPS );
  103. $this->getOutput()->redirect( $url );
  104. return false;
  105. }
  106. $authData = $authManager->getAuthenticationSessionData( $key );
  107. if ( $authData ) {
  108. $authManager->removeAuthenticationSessionData( $key );
  109. $this->isReturn = true;
  110. $this->setRequest( $authData, true );
  111. }
  112. return true;
  113. }
  114. /**
  115. * Handle redirection when the user needs to (re)authenticate.
  116. *
  117. * Send the user to the login form if needed; in case the request was a POST, stash in the
  118. * session and simulate it once the user gets back.
  119. *
  120. * @param string $subPage
  121. * @return bool False if execution should be stopped.
  122. * @throws ErrorPageError When the user is not allowed to use this page.
  123. */
  124. protected function handleReauthBeforeExecute( $subPage ) {
  125. $authManager = AuthManager::singleton();
  126. $request = $this->getRequest();
  127. $key = 'AuthManagerSpecialPage:reauth:' . $this->getName();
  128. $securityLevel = $this->getLoginSecurityLevel();
  129. if ( $securityLevel ) {
  130. $securityStatus = AuthManager::singleton()
  131. ->securitySensitiveOperationStatus( $securityLevel );
  132. if ( $securityStatus === AuthManager::SEC_REAUTH ) {
  133. $queryParams = array_diff_key( $request->getQueryValues(), [ 'title' => true ] );
  134. if ( $request->wasPosted() ) {
  135. // unique ID in case the same special page is open in multiple browser tabs
  136. $uniqueId = MWCryptRand::generateHex( 6 );
  137. $key = $key . ':' . $uniqueId;
  138. $queryParams = [ 'authUniqueId' => $uniqueId ] + $queryParams;
  139. $authData = array_diff_key( $request->getValues(),
  140. $this->getPreservedParams( false ), [ 'title' => 1 ] );
  141. $authManager->setAuthenticationSessionData( $key, $authData );
  142. }
  143. $title = SpecialPage::getTitleFor( 'Userlogin' );
  144. $url = $title->getFullURL( [
  145. 'returnto' => $this->getFullTitle()->getPrefixedDBkey(),
  146. 'returntoquery' => wfArrayToCgi( $queryParams ),
  147. 'force' => $securityLevel,
  148. ], false, PROTO_HTTPS );
  149. $this->getOutput()->redirect( $url );
  150. return false;
  151. } elseif ( $securityStatus !== AuthManager::SEC_OK ) {
  152. throw new ErrorPageError( 'cannotauth-not-allowed-title', 'cannotauth-not-allowed' );
  153. }
  154. }
  155. $uniqueId = $request->getVal( 'authUniqueId' );
  156. if ( $uniqueId ) {
  157. $key = $key . ':' . $uniqueId;
  158. $authData = $authManager->getAuthenticationSessionData( $key );
  159. if ( $authData ) {
  160. $authManager->removeAuthenticationSessionData( $key );
  161. $this->setRequest( $authData, true );
  162. }
  163. }
  164. return true;
  165. }
  166. /**
  167. * Get the default action for this special page, if none is given via URL/POST data.
  168. * Subclasses should override this (or override loadAuth() so this is never called).
  169. * @param string $subPage Subpage of the special page.
  170. * @return string an AuthManager::ACTION_* constant.
  171. */
  172. abstract protected function getDefaultAction( $subPage );
  173. /**
  174. * Return custom message key.
  175. * Allows subclasses to customize messages.
  176. * @param string $defaultKey
  177. * @return string
  178. */
  179. protected function messageKey( $defaultKey ) {
  180. return array_key_exists( $defaultKey, static::$messages )
  181. ? static::$messages[$defaultKey] : $defaultKey;
  182. }
  183. /**
  184. * Allows blacklisting certain request types.
  185. * @return array A list of AuthenticationRequest subclass names
  186. */
  187. protected function getRequestBlacklist() {
  188. return [];
  189. }
  190. /**
  191. * Load or initialize $authAction, $authRequests and $subPage.
  192. * Subclasses should call this from execute() or otherwise ensure the variables are initialized.
  193. * @param string $subPage Subpage of the special page.
  194. * @param string|null $authAction Override auth action specified in request (this is useful
  195. * when the form needs to be changed from <action> to <action>_CONTINUE after a successful
  196. * authentication step)
  197. * @param bool $reset Regenerate the requests even if a cached version is available
  198. */
  199. protected function loadAuth( $subPage, $authAction = null, $reset = false ) {
  200. // Do not load if already loaded, to cut down on the number of getAuthenticationRequests
  201. // calls. This is important for requests which have hidden information so any
  202. // getAuthenticationRequests call would mean putting data into some cache.
  203. if (
  204. !$reset && $this->subPage === $subPage && $this->authAction
  205. && ( !$authAction || $authAction === $this->authAction )
  206. ) {
  207. return;
  208. }
  209. $request = $this->getRequest();
  210. $this->subPage = $subPage;
  211. $this->authAction = $authAction ?: $request->getText( 'authAction' );
  212. if ( !in_array( $this->authAction, static::$allowedActions, true ) ) {
  213. $this->authAction = $this->getDefaultAction( $subPage );
  214. if ( $request->wasPosted() ) {
  215. $continueAction = $this->getContinueAction( $this->authAction );
  216. if ( in_array( $continueAction, static::$allowedActions, true ) ) {
  217. $this->authAction = $continueAction;
  218. }
  219. }
  220. }
  221. $allReqs = AuthManager::singleton()->getAuthenticationRequests(
  222. $this->authAction, $this->getUser() );
  223. $this->authRequests = array_filter( $allReqs, function ( $req ) use ( $subPage ) {
  224. return !in_array( get_class( $req ), $this->getRequestBlacklist(), true );
  225. } );
  226. }
  227. /**
  228. * Returns true if this is not the first step of the authentication.
  229. * @return bool
  230. */
  231. protected function isContinued() {
  232. return in_array( $this->authAction, [
  233. AuthManager::ACTION_LOGIN_CONTINUE,
  234. AuthManager::ACTION_CREATE_CONTINUE,
  235. AuthManager::ACTION_LINK_CONTINUE,
  236. ], true );
  237. }
  238. /**
  239. * Gets the _CONTINUE version of an action.
  240. * @param string $action An AuthManager::ACTION_* constant.
  241. * @return string An AuthManager::ACTION_*_CONTINUE constant.
  242. */
  243. protected function getContinueAction( $action ) {
  244. switch ( $action ) {
  245. case AuthManager::ACTION_LOGIN:
  246. $action = AuthManager::ACTION_LOGIN_CONTINUE;
  247. break;
  248. case AuthManager::ACTION_CREATE:
  249. $action = AuthManager::ACTION_CREATE_CONTINUE;
  250. break;
  251. case AuthManager::ACTION_LINK:
  252. $action = AuthManager::ACTION_LINK_CONTINUE;
  253. break;
  254. }
  255. return $action;
  256. }
  257. /**
  258. * Checks whether AuthManager is ready to perform the action.
  259. * ACTION_CHANGE needs special verification (AuthManager::allowsAuthenticationData*) which is
  260. * the caller's responsibility.
  261. * @param string $action One of the AuthManager::ACTION_* constants in static::$allowedActions
  262. * @return bool
  263. * @throws LogicException if $action is invalid
  264. */
  265. protected function isActionAllowed( $action ) {
  266. $authManager = AuthManager::singleton();
  267. if ( !in_array( $action, static::$allowedActions, true ) ) {
  268. throw new InvalidArgumentException( 'invalid action: ' . $action );
  269. }
  270. // calling getAuthenticationRequests can be expensive, avoid if possible
  271. $requests = ( $action === $this->authAction ) ? $this->authRequests
  272. : $authManager->getAuthenticationRequests( $action );
  273. if ( !$requests ) {
  274. // no provider supports this action in the current state
  275. return false;
  276. }
  277. switch ( $action ) {
  278. case AuthManager::ACTION_LOGIN:
  279. case AuthManager::ACTION_LOGIN_CONTINUE:
  280. return $authManager->canAuthenticateNow();
  281. case AuthManager::ACTION_CREATE:
  282. case AuthManager::ACTION_CREATE_CONTINUE:
  283. return $authManager->canCreateAccounts();
  284. case AuthManager::ACTION_LINK:
  285. case AuthManager::ACTION_LINK_CONTINUE:
  286. return $authManager->canLinkAccounts();
  287. case AuthManager::ACTION_CHANGE:
  288. case AuthManager::ACTION_REMOVE:
  289. case AuthManager::ACTION_UNLINK:
  290. return true;
  291. default:
  292. // should never reach here but makes static code analyzers happy
  293. throw new InvalidArgumentException( 'invalid action: ' . $action );
  294. }
  295. }
  296. /**
  297. * @param string $action One of the AuthManager::ACTION_* constants
  298. * @param AuthenticationRequest[] $requests
  299. * @return AuthenticationResponse
  300. * @throws LogicException if $action is invalid
  301. */
  302. protected function performAuthenticationStep( $action, array $requests ) {
  303. if ( !in_array( $action, static::$allowedActions, true ) ) {
  304. throw new InvalidArgumentException( 'invalid action: ' . $action );
  305. }
  306. $authManager = AuthManager::singleton();
  307. $returnToUrl = $this->getPageTitle( 'return' )
  308. ->getFullURL( $this->getPreservedParams( true ), false, PROTO_HTTPS );
  309. switch ( $action ) {
  310. case AuthManager::ACTION_LOGIN:
  311. return $authManager->beginAuthentication( $requests, $returnToUrl );
  312. case AuthManager::ACTION_LOGIN_CONTINUE:
  313. return $authManager->continueAuthentication( $requests );
  314. case AuthManager::ACTION_CREATE:
  315. return $authManager->beginAccountCreation( $this->getUser(), $requests,
  316. $returnToUrl );
  317. case AuthManager::ACTION_CREATE_CONTINUE:
  318. return $authManager->continueAccountCreation( $requests );
  319. case AuthManager::ACTION_LINK:
  320. return $authManager->beginAccountLink( $this->getUser(), $requests, $returnToUrl );
  321. case AuthManager::ACTION_LINK_CONTINUE:
  322. return $authManager->continueAccountLink( $requests );
  323. case AuthManager::ACTION_CHANGE:
  324. case AuthManager::ACTION_REMOVE:
  325. case AuthManager::ACTION_UNLINK:
  326. if ( count( $requests ) > 1 ) {
  327. throw new InvalidArgumentException( 'only one auth request can be changed at a time' );
  328. } elseif ( !$requests ) {
  329. throw new InvalidArgumentException( 'no auth request' );
  330. }
  331. $req = reset( $requests );
  332. $status = $authManager->allowsAuthenticationDataChange( $req );
  333. Hooks::run( 'ChangeAuthenticationDataAudit', [ $req, $status ] );
  334. if ( !$status->isGood() ) {
  335. return AuthenticationResponse::newFail( $status->getMessage() );
  336. }
  337. $authManager->changeAuthenticationData( $req );
  338. return AuthenticationResponse::newPass();
  339. default:
  340. // should never reach here but makes static code analyzers happy
  341. throw new InvalidArgumentException( 'invalid action: ' . $action );
  342. }
  343. }
  344. /**
  345. * Attempts to do an authentication step with the submitted data.
  346. * Subclasses should probably call this from execute().
  347. * @return false|Status
  348. * - false if there was no submit at all
  349. * - a good Status wrapping an AuthenticationResponse if the form submit was successful.
  350. * This does not necessarily mean that the authentication itself was successful; see the
  351. * response for that.
  352. * - a bad Status for form errors.
  353. */
  354. protected function trySubmit() {
  355. $status = false;
  356. $form = $this->getAuthForm( $this->authRequests, $this->authAction );
  357. $form->setSubmitCallback( [ $this, 'handleFormSubmit' ] );
  358. if ( $this->getRequest()->wasPosted() ) {
  359. // handle tokens manually; $form->tryAuthorizedSubmit only works for logged-in users
  360. $requestTokenValue = $this->getRequest()->getVal( $this->getTokenName() );
  361. $sessionToken = $this->getToken();
  362. if ( $sessionToken->wasNew() ) {
  363. return Status::newFatal( $this->messageKey( 'authform-newtoken' ) );
  364. } elseif ( !$requestTokenValue ) {
  365. return Status::newFatal( $this->messageKey( 'authform-notoken' ) );
  366. } elseif ( !$sessionToken->match( $requestTokenValue ) ) {
  367. return Status::newFatal( $this->messageKey( 'authform-wrongtoken' ) );
  368. }
  369. $form->prepareForm();
  370. $status = $form->trySubmit();
  371. // HTMLForm submit return values are a mess; let's ensure it is false or a Status
  372. // FIXME this probably should be in HTMLForm
  373. if ( $status === true ) {
  374. // not supposed to happen since our submit handler should always return a Status
  375. throw new UnexpectedValueException( 'HTMLForm::trySubmit() returned true' );
  376. } elseif ( $status === false ) {
  377. // form was not submitted; nothing to do
  378. } elseif ( $status instanceof Status ) {
  379. // already handled by the form; nothing to do
  380. } elseif ( $status instanceof StatusValue ) {
  381. // in theory not an allowed return type but nothing stops the submit handler from
  382. // accidentally returning it so best check and fix
  383. $status = Status::wrap( $status );
  384. } elseif ( is_string( $status ) ) {
  385. $status = Status::newFatal( new RawMessage( '$1', $status ) );
  386. } elseif ( is_array( $status ) ) {
  387. if ( is_string( reset( $status ) ) ) {
  388. $status = Status::newFatal( ...$status );
  389. } elseif ( is_array( reset( $status ) ) ) {
  390. $status = Status::newGood();
  391. foreach ( $status as $message ) {
  392. $status->fatal( ...$message );
  393. }
  394. } else {
  395. throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return value: '
  396. . 'first element of array is ' . gettype( reset( $status ) ) );
  397. }
  398. } else {
  399. // not supposed to happen but HTMLForm does not actually verify the return type
  400. // from the submit callback; better safe then sorry
  401. throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return type: '
  402. . gettype( $status ) );
  403. }
  404. if ( ( !$status || !$status->isOK() ) && $this->isReturn ) {
  405. // This is awkward. There was a form validation error, which means the data was not
  406. // passed to AuthManager. Normally we would display the form with an error message,
  407. // but for the data we received via the redirect flow that would not be helpful at all.
  408. // Let's just submit the data to AuthManager directly instead.
  409. LoggerFactory::getInstance( 'authentication' )
  410. ->warning( 'Validation error on return', [ 'data' => $form->mFieldData,
  411. 'status' => $status->getWikiText() ] );
  412. $status = $this->handleFormSubmit( $form->mFieldData );
  413. }
  414. }
  415. $changeActions = [
  416. AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK
  417. ];
  418. if ( in_array( $this->authAction, $changeActions, true ) && $status && !$status->isOK() ) {
  419. Hooks::run( 'ChangeAuthenticationDataAudit', [ reset( $this->authRequests ), $status ] );
  420. }
  421. return $status;
  422. }
  423. /**
  424. * Submit handler callback for HTMLForm
  425. * @private
  426. * @param array $data Submitted data
  427. * @return Status
  428. */
  429. public function handleFormSubmit( $data ) {
  430. $requests = AuthenticationRequest::loadRequestsFromSubmission( $this->authRequests, $data );
  431. $response = $this->performAuthenticationStep( $this->authAction, $requests );
  432. // we can't handle FAIL or similar as failure here since it might require changing the form
  433. return Status::newGood( $response );
  434. }
  435. /**
  436. * Returns URL query parameters which can be used to reload the page (or leave and return) while
  437. * preserving all information that is necessary for authentication to continue. These parameters
  438. * will be preserved in the action URL of the form and in the return URL for redirect flow.
  439. * @param bool $withToken Include CSRF token
  440. * @return array
  441. */
  442. protected function getPreservedParams( $withToken = false ) {
  443. $params = [];
  444. if ( $this->authAction !== $this->getDefaultAction( $this->subPage ) ) {
  445. $params['authAction'] = $this->getContinueAction( $this->authAction );
  446. }
  447. if ( $withToken ) {
  448. $params[$this->getTokenName()] = $this->getToken()->toString();
  449. }
  450. return $params;
  451. }
  452. /**
  453. * Generates a HTMLForm descriptor array from a set of authentication requests.
  454. * @param AuthenticationRequest[] $requests
  455. * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
  456. * @return array
  457. */
  458. protected function getAuthFormDescriptor( $requests, $action ) {
  459. $fieldInfo = AuthenticationRequest::mergeFieldInfo( $requests );
  460. $formDescriptor = $this->fieldInfoToFormDescriptor( $requests, $fieldInfo, $action );
  461. $this->addTabIndex( $formDescriptor );
  462. return $formDescriptor;
  463. }
  464. /**
  465. * @param AuthenticationRequest[] $requests
  466. * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
  467. * @return HTMLForm
  468. */
  469. protected function getAuthForm( array $requests, $action ) {
  470. $formDescriptor = $this->getAuthFormDescriptor( $requests, $action );
  471. $context = $this->getContext();
  472. if ( $context->getRequest() !== $this->getRequest() ) {
  473. // We have overridden the request, need to make sure the form uses that too.
  474. $context = new DerivativeContext( $this->getContext() );
  475. $context->setRequest( $this->getRequest() );
  476. }
  477. $form = HTMLForm::factory( 'ooui', $formDescriptor, $context );
  478. $form->setAction( $this->getFullTitle()->getFullURL( $this->getPreservedParams() ) );
  479. $form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() );
  480. $form->addHiddenField( 'authAction', $this->authAction );
  481. $form->suppressDefaultSubmit( !$this->needsSubmitButton( $requests ) );
  482. return $form;
  483. }
  484. /**
  485. * Display the form.
  486. * @param false|Status|StatusValue $status A form submit status, as in HTMLForm::trySubmit()
  487. */
  488. protected function displayForm( $status ) {
  489. if ( $status instanceof StatusValue ) {
  490. $status = Status::wrap( $status );
  491. }
  492. $form = $this->getAuthForm( $this->authRequests, $this->authAction );
  493. $form->prepareForm()->displayForm( $status );
  494. }
  495. /**
  496. * Returns true if the form built from the given AuthenticationRequests needs a submit button.
  497. * Providers using redirect flow (e.g. Google login) need their own submit buttons; if using
  498. * one of those custom buttons is the only way to proceed, there is no point in displaying the
  499. * default button which won't do anything useful.
  500. *
  501. * @param AuthenticationRequest[] $requests An array of AuthenticationRequests from which the
  502. * form will be built
  503. * @return bool
  504. */
  505. protected function needsSubmitButton( array $requests ) {
  506. $customSubmitButtonPresent = false;
  507. // Secondary and preauth providers always need their data; they will not care what button
  508. // is used, so they can be ignored. So can OPTIONAL buttons createdby primary providers;
  509. // that's the point in being optional. Se we need to check whether all primary providers
  510. // have their own buttons and whether there is at least one button present.
  511. foreach ( $requests as $req ) {
  512. if ( $req->required === AuthenticationRequest::PRIMARY_REQUIRED ) {
  513. if ( $this->hasOwnSubmitButton( $req ) ) {
  514. $customSubmitButtonPresent = true;
  515. } else {
  516. return true;
  517. }
  518. }
  519. }
  520. return !$customSubmitButtonPresent;
  521. }
  522. /**
  523. * Checks whether the given AuthenticationRequest has its own submit button.
  524. * @param AuthenticationRequest $req
  525. * @return bool
  526. */
  527. protected function hasOwnSubmitButton( AuthenticationRequest $req ) {
  528. foreach ( $req->getFieldInfo() as $field => $info ) {
  529. if ( $info['type'] === 'button' ) {
  530. return true;
  531. }
  532. }
  533. return false;
  534. }
  535. /**
  536. * Adds a sequential tabindex starting from 1 to all form elements. This way the user can
  537. * use the tab key to traverse the form without having to step through all links and such.
  538. * @param array &$formDescriptor
  539. */
  540. protected function addTabIndex( &$formDescriptor ) {
  541. $i = 1;
  542. foreach ( $formDescriptor as $field => &$definition ) {
  543. $class = false;
  544. if ( array_key_exists( 'class', $definition ) ) {
  545. $class = $definition['class'];
  546. } elseif ( array_key_exists( 'type', $definition ) ) {
  547. $class = HTMLForm::$typeMappings[$definition['type']];
  548. }
  549. if ( $class !== HTMLInfoField::class ) {
  550. $definition['tabindex'] = $i;
  551. $i++;
  552. }
  553. }
  554. }
  555. /**
  556. * Returns the CSRF token.
  557. * @return Token
  558. */
  559. protected function getToken() {
  560. return $this->getRequest()->getSession()->getToken( 'AuthManagerSpecialPage:'
  561. . $this->getName() );
  562. }
  563. /**
  564. * Returns the name of the CSRF token (under which it should be found in the POST or GET data).
  565. * @return string
  566. */
  567. protected function getTokenName() {
  568. return 'wpAuthToken';
  569. }
  570. /**
  571. * Turns a field info array into a form descriptor. Behavior can be modified by the
  572. * AuthChangeFormFields hook.
  573. * @param AuthenticationRequest[] $requests
  574. * @param array $fieldInfo Field information, in the format used by
  575. * AuthenticationRequest::getFieldInfo()
  576. * @param string $action One of the AuthManager::ACTION_* constants
  577. * @return array A form descriptor that can be passed to HTMLForm
  578. */
  579. protected function fieldInfoToFormDescriptor( array $requests, array $fieldInfo, $action ) {
  580. $formDescriptor = [];
  581. foreach ( $fieldInfo as $fieldName => $singleFieldInfo ) {
  582. $formDescriptor[$fieldName] = self::mapSingleFieldInfo( $singleFieldInfo, $fieldName );
  583. }
  584. $requestSnapshot = serialize( $requests );
  585. $this->onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action );
  586. \Hooks::run( 'AuthChangeFormFields', [ $requests, $fieldInfo, &$formDescriptor, $action ] );
  587. if ( $requestSnapshot !== serialize( $requests ) ) {
  588. LoggerFactory::getInstance( 'authentication' )->warning(
  589. 'AuthChangeFormFields hook changed auth requests' );
  590. }
  591. // Process the special 'weight' property, which is a way for AuthChangeFormFields hook
  592. // subscribers (who only see one field at a time) to influence ordering.
  593. self::sortFormDescriptorFields( $formDescriptor );
  594. return $formDescriptor;
  595. }
  596. /**
  597. * Maps an authentication field configuration for a single field (as returned by
  598. * AuthenticationRequest::getFieldInfo()) to a HTMLForm field descriptor.
  599. * @param array $singleFieldInfo
  600. * @param string $fieldName
  601. * @return array
  602. */
  603. protected static function mapSingleFieldInfo( $singleFieldInfo, $fieldName ) {
  604. $type = self::mapFieldInfoTypeToFormDescriptorType( $singleFieldInfo['type'] );
  605. $descriptor = [
  606. 'type' => $type,
  607. // Do not prefix input name with 'wp'. This is important for the redirect flow.
  608. 'name' => $fieldName,
  609. ];
  610. if ( $type === 'submit' && isset( $singleFieldInfo['label'] ) ) {
  611. $descriptor['default'] = $singleFieldInfo['label']->plain();
  612. } elseif ( $type !== 'submit' ) {
  613. $descriptor += array_filter( [
  614. // help-message is omitted as it is usually not really useful for a web interface
  615. 'label-message' => self::getField( $singleFieldInfo, 'label' ),
  616. ] );
  617. if ( isset( $singleFieldInfo['options'] ) ) {
  618. $descriptor['options'] = array_flip( array_map( function ( $message ) {
  619. /** @var Message $message */
  620. return $message->parse();
  621. }, $singleFieldInfo['options'] ) );
  622. }
  623. if ( isset( $singleFieldInfo['value'] ) ) {
  624. $descriptor['default'] = $singleFieldInfo['value'];
  625. }
  626. if ( empty( $singleFieldInfo['optional'] ) ) {
  627. $descriptor['required'] = true;
  628. }
  629. }
  630. return $descriptor;
  631. }
  632. /**
  633. * Sort the fields of a form descriptor by their 'weight' property. (Fields with higher weight
  634. * are shown closer to the bottom; weight defaults to 0. Negative weight is allowed.)
  635. * Keep order if weights are equal.
  636. * @param array &$formDescriptor
  637. * @return array
  638. */
  639. protected static function sortFormDescriptorFields( array &$formDescriptor ) {
  640. $i = 0;
  641. foreach ( $formDescriptor as &$field ) {
  642. $field['__index'] = $i++;
  643. }
  644. uasort( $formDescriptor, function ( $first, $second ) {
  645. return self::getField( $first, 'weight', 0 ) <=> self::getField( $second, 'weight', 0 )
  646. ?: $first['__index'] <=> $second['__index'];
  647. } );
  648. foreach ( $formDescriptor as &$field ) {
  649. unset( $field['__index'] );
  650. }
  651. }
  652. /**
  653. * Get an array value, or a default if it does not exist.
  654. * @param array $array
  655. * @param string $fieldName
  656. * @param mixed|null $default
  657. * @return mixed
  658. */
  659. protected static function getField( array $array, $fieldName, $default = null ) {
  660. if ( array_key_exists( $fieldName, $array ) ) {
  661. return $array[$fieldName];
  662. } else {
  663. return $default;
  664. }
  665. }
  666. /**
  667. * Maps AuthenticationRequest::getFieldInfo() types to HTMLForm types
  668. * @param string $type
  669. * @return string
  670. * @throws \LogicException
  671. */
  672. protected static function mapFieldInfoTypeToFormDescriptorType( $type ) {
  673. $map = [
  674. 'string' => 'text',
  675. 'password' => 'password',
  676. 'select' => 'select',
  677. 'checkbox' => 'check',
  678. 'multiselect' => 'multiselect',
  679. 'button' => 'submit',
  680. 'hidden' => 'hidden',
  681. 'null' => 'info',
  682. ];
  683. if ( !array_key_exists( $type, $map ) ) {
  684. throw new \LogicException( 'invalid field type: ' . $type );
  685. }
  686. return $map[$type];
  687. }
  688. }