['type' => 'string'], ]; } public function loadFromSubmission ( array $data ) { if ( isset( $data['query'] ) ) { parse_str($data['query'], $this->query); return true; } return false; } } class DummyAuthRequest extends AuthenticationRequest { public function getFieldInfo() {} } class RedirectAuthProvider extends AbstractPrimaryAuthenticationProvider { const BUTTON_NAME = 'redirectauth'; const TOKEN_SALT = 'RedirectAuthProvider'; const RETURNURL_SESSION_KEY = 'redirectAuthReturnToUrl'; const EXTERNALID_SESSION_KEY = 'redirectAuthExternalId'; public function getAuthenticationRequests ( $action, array $options) { global $wgRedirectAuth_providerDetails; if ($action == AuthManager::ACTION_LOGIN) { // 1. Show the "Log in via ..." button on Special:UserLogin if (RequestContext::getMain()->getRequest()->getVal('noexternallogin')) { // The user said that they already have a wiki account with regular credentials, // so we prompt them to log in with these credentials. Showing the "Log in with ..." // button in that case would be confusing. return []; } return [new RedirectAuthRequest(wfMessage('redirectauth-log-in-with', $wgRedirectAuth_providerDetails->getName()))]; } else if ($action == AuthManager::ACTION_LINK) { $user = RequestContext::getMain()->getUser(); if (Mapper::getExternalIdByLocalId($user->mId) == null) { return [new RedirectAuthRequest(wfMessage('redirectauth-link-your', $wgRedirectAuth_providerDetails->getName()))]; } } else if ($action == AuthManager::ACTION_REMOVE) { $user = RequestContext::getMain()->getUser(); if (Mapper::getExternalIdByLocalId($user->mId) != null) { return [new RedirectAuthRequest(wfMessage('redirectauth-unlink-your', $wgRedirectAuth_providerDetails->getName()))]; } } return []; } public function beginPrimaryAuthentication (array $reqs) { $req = AuthenticationRequest::getRequestByClass( $reqs, RedirectAuthRequest::class ); if ( !$req ) { return AuthenticationResponse::newAbstain(); } // 2. The button has been clicked so we redirect the user to the external identity provider $this->manager->setAuthenticationSessionData(self::RETURNURL_SESSION_KEY, $req->returnToUrl); global $wgRedirectAuth_providerDetails; $token = $this->manager->getRequest()->getSession()->getToken(self::TOKEN_SALT); $redirectUrl = $wgRedirectAuth_providerDetails->getRedirectUrl($token); return AuthenticationResponse::newRedirect([new ReturnAuthRequest()], $redirectUrl); } public function continuePrimaryAuthentication (array $reqs) { $req = AuthenticationRequest::getRequestByClass( $reqs, ReturnAuthRequest::class ); if ($req) { // 4. we are back \o/ global $wgRedirectAuth_providerDetails; $userInfo = $wgRedirectAuth_providerDetails->getUserInfo($req->query); if ($userInfo->error) { return AuthenticationResponse::newFail(wfMessage('redirectauth-auth-failed', $userInfo->error)); } $confirmed = false; } else { // 5. the user has chosen what to do $req = AuthenticationRequest::getRequestByClass( $reqs, CreateOrLoginAuthRequest::class, true); // I don't understand why the third parameter $allowSubclasses needs to be set to true here ... but hey it works if ($req) { $userInfo = $req->userInfo; if ($req->choice == 'log-in' || $req->choice == 'mine') { $params = [ 'returnto' => 'Special:LinkAccounts', 'force' => 'LinkAccounts', // prevents the Sign up button from being shown 'noexternallogin' => true // recognized in getAuthenticationRequests to suppress our login button ]; if ($req->choice == 'mine') { $params['wpName'] = $req->userInfo->userName; // just already fill it out for better UX } $target = SpecialPage::getTitleFor('Userlogin')->getLocalURL($params); // AuthenticationResponse::newRedirect throws an error if the requests array is empty so we just pass a dummy request return AuthenticationResponse::newRedirect([new DummyAuthRequest()], $target); } else if ($req->choice == 'create-other') { return AuthenticationResponse::newUI([new OtherUsernameAuthRequest($userInfo)], WfMessage('redirectauth-pick-other-username-taken', $userInfo->userName)); } $confirmed = true; } else { $req = AuthenticationRequest::getRequestByClass( $reqs, OtherUsernameAuthRequest::class, true); if ($req) { $confirmed = true; $userInfo = $req->userInfo; $userInfo->userName = $req->username; } else { return AuthenticationResponse::newAbstain(); } } } $userFactory = MediaWikiServices::getInstance()->getUserFactory(); $localUser = Mapper::getLocalUser($userInfo->userId); if ($localUser != null) { // The external user id has already been linked to a wiki account, so we're done here. return AuthenticationResponse::newPass($localUser->mName); } // The external user id has not yet been linked to a wiki account. $user = $userFactory->newFromName($userInfo->userName, UserFactory::RIGOR_CREATABLE); if ($user == null) { return AuthenticationResponse::newUI([new OtherUsernameAuthRequest($userInfo)], WfMessage('redirectauth-pick-other-username-invalid')); } if ($user->isRegistered()) { // namespace collision if ($req instanceof OtherUsernameAuthRequest) { return AuthenticationResponse::newUI([new OtherUsernameAuthRequest($userInfo)], WfMessage('redirectauth-pick-other-username-taken', $userInfo->userName)); } // We create the link manually because if the system message contained [[User:$1|]] MediaWiki would create an // action=edit link in case the user page doesn't exist ... and linking an edit page would be confusing. $factory = MediaWikiServices::getInstance()->getLinkRendererFactory(); $linkRenderer = $factory->create(); $link = $linkRenderer->makeKnownLink(new TitleValue( NS_USER, $userInfo->userName), $userInfo->userName, ['target' => '_blank']); return AuthenticationResponse::newUI([new CreateOrLoginAuthRequest($userInfo, true)], wfMessage("redirectauth-collision")->rawParams($link)); } // The username is available. if (!$confirmed) { // ideally we would set the 3rd parameter of newUI to 'info' but that isn't supported (https://phabricator.wikimedia.org/T320671) return AuthenticationResponse::newUI([new CreateOrLoginAuthRequest($userInfo, false)], wfMessage("redirectauth-create-or-log-in")); } // The user has confirmed that they want to create an account with the username. // We cannot directly call Mapper::createMapping here because the $user does not have an id yet. // So instead we store $userInfo->userId in the session and retrieve it in autoCreatedAccount. $this->manager->setAuthenticationSessionData(self::EXTERNALID_SESSION_KEY, $userInfo->userId); return AuthenticationResponse::newPass($user->mName); } public function autoCreatedAccount($user, $source) { // 6. account has been created ... save mapping in database $externalId = $this->manager->getAuthenticationSessionData(self::EXTERNALID_SESSION_KEY); Mapper::createMapping($user->getId(), $externalId); $this->manager->removeAuthenticationSessionData(self::EXTERNALID_SESSION_KEY); } public function beginPrimaryAccountLink($user, array $reqs) { return $this->beginPrimaryAuthentication($reqs); } public function continuePrimaryAccountLink($user, array $reqs) { $req = AuthenticationRequest::getRequestByClass( $reqs, ReturnAuthRequest::class ); if ($req) { global $wgRedirectAuth_providerDetails; $userInfo = $wgRedirectAuth_providerDetails->getUserInfo($req->query); if ($userInfo->error) { return AuthenticationResponse::newFail(wfMessage('redirectauth-auth-failed', $userInfo->error)); } $existingLocal = Mapper::getLocalUser($userInfo->userId); if ($existingLocal != null) { // FUTURE: instead return UI allowing the user to update the link? return AuthenticationResponse::newFail(wfMessage("redirectauth-error-already-linked", $wgRedirectAuth_providerDetails->getName(), $existingLocal->mName)); } Mapper::createMapping($user->mId, $userInfo->userId); return AuthenticationResponse::newPass(); } return AuthenticationResponse::newAbstain(); } public function testUserExists ( $username, $flags=User::READ_NORMAL) { global $wgRedirectAuth_providerDetails; return $wgRedirectAuth_providerDetails->testUserExists($username); } public function accountCreationType () { return PrimaryAuthenticationProvider::TYPE_LINK; } public function providerAllowsAuthenticationDataChange (AuthenticationRequest $req, $checkData=true) { return StatusValue::newGood(); } public function providerChangeAuthenticationData (AuthenticationRequest $req) { if ($req->action == AuthManager::ACTION_REMOVE) { $user = RequestContext::getMain()->getUser(); Mapper::deleteMapping($user->mId); } } public function beginPrimaryAccountCreation ( $user, $creator, array $reqs) { return AuthenticationResponse::newAbstain(); } } class SpecialRedirectAuthReturn extends UnlistedSpecialPage { function __construct() { parent::__construct('RedirectAuthReturn'); } function execute($param) { // 3. The user was redirected back to the wiki. $out = $this->getOutput(); $request = $this->getRequest(); $session = $request->getSession(); $token = $session->getToken(RedirectAuthProvider::TOKEN_SALT); global $wgRedirectAuth_providerDetails; $state = $wgRedirectAuth_providerDetails->getStateFromQuery($request->getQueryValuesOnly()); if (!$token->match($state)) { $out->prepareErrorPage('State mismatch error'); $out->addWikiMsg('redirectauth-try-again'); $out->setStatusCode(400); return; } $authData = $session->getSecret( 'authData' ); $redirectUrl = $authData[RedirectAuthProvider::RETURNURL_SESSION_KEY] ?? false; if (!$redirectUrl) { $out->prepareErrorPage('Failed to retrieve return URL from session'); $out->addWikiMsg('redirectauth-try-again'); $out->setStatusCode(400); return; } $redirectUrl = wfAppendQuery($redirectUrl, [ 'query' => $request->getRawQueryString(), ]); $out->redirect($redirectUrl); } } class CreateOrLogInAuthRequest extends AuthenticationRequest { public function __construct(UserInfo $userInfo, bool $nameTaken) { $this->userInfo = $userInfo; $this->nameTaken = $nameTaken; } public function getFieldInfo() { // Normally field info types are mapped to form descriptor types by // AuthManagerSpecialPage::mapFieldInfoTypeToFormDescriptorType // but since that function doesn't support 'type'=>'radio' we override // the mapping in the onAuthChangeFormFields hook. // Note that we still have to return the field here because // AuthenticationRequest::loadFromSubmission uses the field info. return ['choice' => ['type' => 'string']]; } } class OtherUsernameAuthRequest extends AuthenticationRequest { public function __construct(UserInfo $userInfo) { $this->userInfo = $userInfo; } public function getFieldInfo() { return [ 'username' => [ 'type' => 'string', 'value' => $this->userInfo->userName, ] ]; } } /** classes that are less important **/ class UserInfo { public $error = null; public $userId = null; public $userName = null; static function err(string $msg) { $ret = new UserInfo; $ret->error = $msg; return $ret; } static function ok(string $id, string $name) { $ret = new UserInfo; $ret->userId = $id; $ret->userName = $name; return $ret; } } class Hooks { public static function onLoadExtensionSchemaUpdates( $updater ) { $updater->addExtensionTable(Mapper::TABLE_NAME, $GLOBALS['wgExtensionDirectory'] . '/RedirectAuth/schema.sql'); } public static function onAuthChangeFormFields( $requests, $fieldInfo, &$formDescriptor, $action ) { if ($action == AuthManager::ACTION_LOGIN) { $req = AuthenticationRequest::getRequestByClass( $requests, RedirectAuthRequest::class); if ($req) { $formDescriptor[RedirectAuthProvider::BUTTON_NAME]['weight'] = -2; // ideally we would set the autofocus attribute for the button but that isn't supported (https://phabricator.wikimedia.org/T320672) } } else if ($action == AuthManager::ACTION_LOGIN_CONTINUE) { $req = AuthenticationRequest::getRequestByClass( $requests, CreateOrLoginAuthRequest::class, true); if ($req) { global $wgRedirectAuth_providerDetails; $providerName = $wgRedirectAuth_providerDetails->getName(); if ($req->nameTaken) { $options = [ wfMessage('redirectauth-collision-opt-mine')->parse() => 'mine', wfMessage('redirectauth-collision-opt-log-in', $providerName)->parse() => 'log-in', wfMessage('redirectauth-collision-opt-create', $providerName)->parse() => 'create-other', ]; } else { $options = [ wfMessage('redirectauth-opt-create', $requests[0]->userInfo->userName)->parse() => 'create', wfMessage('redirectauth-opt-log-in', $providerName)->parse() => 'log-in', ]; } $formDescriptor['choice'] = [ 'type' => 'radio', 'options' => $options, 'default' => 'create' ]; } } } public static function onGetPreferences( User $user, array &$preferences ) { global $wgRedirectAuth_providerDetails; $externalId = Mapper::getExternalIdByLocalId($user->mId); if ($externalId) { $formatterUrl = $wgRedirectAuth_providerDetails->getUserFormatterUrl(); if ($formatterUrl) { $html = Html::rawElement('a', ['href' => str_replace('$1', $externalId, $formatterUrl)], $externalId); } else { $html = Html::rawElement('span', [], $externalId); } $special = 'UnlinkAccounts'; $msg = wfMessage('redirectauth-unlink-account'); } else { $special = 'LinkAccounts'; $msg = wfMessage('redirectauth-link-your', $wgRedirectAuth_providerDetails->getName()); $html = ''; } $html .= ' ' . new \OOUI\ButtonWidget( [ 'href' => SpecialPage::getTitleFor( $special )->getLinkURL(), 'label' => $msg->text(), ] ); $preferences['redirectauth'] = [ 'type' => 'info', 'label-message' => 'redirectauth-linked-account', 'section' => 'personal/info', 'default' => $html, 'raw' => true // we have to use raw HTML because form descriptors aren't flexible enough ]; } public static function onSpecialStatsAddExtra( &$extraStats, RequestContext $context ) { // ideally we would pass $wgRedirectAuth_providerDetails->getName() as a parameter to the // redirectauth-count message but that isn't supported (https://phabricator.wikimedia.org/T320674) $extraStats['redirectauth-count'] = Mapper::count(); } } class Mapper { const TABLE_NAME = 'redirectauth_mappings'; static function getLocalUser(string $externalId) { $results = wfGetDB(DB_PRIMARY)->select( [self::TABLE_NAME, 'user'], ['local_id', 'user_name'], ['external_id' => $externalId], __METHOD__, [], ['user' => ['JOIN', 'user_id=local_id']] ); if ($results->numRows() == 0) { return null; } $userFactory = MediaWikiServices::getInstance()->getUserFactory(); $cur = $results->current(); $user = $userFactory->newFromId($cur->local_id); $user->mName = $cur->user_name; return $user; } static function createMapping(int $localId, string $externalId) { wfGetDB(DB_PRIMARY)->insert( self::TABLE_NAME, [ 'local_id' => $localId, 'external_id' => $externalId, ], __METHOD__ ); } static function getExternalIdByLocalId(int $localId) { $results = wfGetDB(DB_PRIMARY)->select( self::TABLE_NAME, ['external_id'], ['local_id' => $localId], __METHOD__ ); if ($results->numRows() == 0) { return null; } return $results->current()->external_id; } static function getExternalIdByUsername(string $username) { // While this function is not used by this extension it is provided for // extension users to facilitate the adding of external profile links to // the sidebar via the SkinBuildSidebar hook. $results = wfGetDB(DB_PRIMARY)->select( [self::TABLE_NAME, 'user'], ['external_id'], [], __METHOD__, [], ['user' => ['INNER JOIN', ['user_id=local_id', 'user_name' => $username]]] ); if ($results->numRows() == 0) { return null; } return $results->current()->external_id; } static function deleteMapping(int $wikiUserId) { $results = wfGetDB(DB_PRIMARY)->delete( self::TABLE_NAME, ['local_id' => $wikiUserId], __METHOD__ ); } static function count() { return wfGetDB(DB_PRIMARY)->selectRowCount(self::TABLE_NAME); } }