AuthManagerSpecialPage.php 28 KB

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