summaryrefslogtreecommitdiff
path: root/RedirectAuth.php
diff options
context:
space:
mode:
Diffstat (limited to 'RedirectAuth.php')
-rw-r--r--RedirectAuth.php562
1 files changed, 562 insertions, 0 deletions
diff --git a/RedirectAuth.php b/RedirectAuth.php
new file mode 100644
index 0000000..3b15337
--- /dev/null
+++ b/RedirectAuth.php
@@ -0,0 +1,562 @@
+<?php
+
+namespace MediaWiki\Extension\RedirectAuth;
+
+use MediaWiki\Auth\AbstractPrimaryAuthenticationProvider;
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\ButtonAuthenticationRequest;
+use MediaWiki\Auth\PrimaryAuthenticationProvider;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\User\UserFactory;
+use Html;
+use RequestContext;
+use SpecialPage;
+use StatusValue;
+use TitleValue;
+use UnlistedSpecialPage;
+use User;
+
+/**
+ * This is the class that extension users have to implement.
+ */
+abstract class ProviderDetails {
+ /**
+ * Returns the name of the authentication provider.
+ */
+ abstract function getName(): string;
+
+ /**
+ * Returns the HTTPS URL where the user should be redirected if they want to log in.
+ * The passed state must be incorporated into the URL (and must be sent back
+ * by the identity provider on successful authentication).
+ */
+ abstract function getRedirectUrl(string $state): string;
+
+ /**
+ * Constructs the UserInfo from the URL query returned by the identity provider.
+ * Conventionally the query just contains an access token, so this function
+ * probably retrieves the user info from some HTTP API.
+ */
+ abstract function getUserInfo(array $query): UserInfo;
+
+ /**
+ * Returns the state from the returned URL query.
+ */
+ function getStateFromQuery($query): ?string {
+ return $query['state'];
+ }
+
+ /**
+ * Optionally returns a formatter URL, which is used to linkify
+ * external user ids on the preferences page. Use $1 for the placeholder
+ * where the external user id should be substituted. By default this
+ * function returns null, which simply results in no link in the preferences.
+ */
+ function getUserFormatterUrl(): ?string {
+ return null;
+ }
+
+ /**
+ * Optionally implement this function to prevent the creation of accounts
+ * via e.g. Special:CreateAccount if the username is already taken at the
+ * external identity provider.
+ */
+ function testUserExists ( $username ): bool {
+ return false;
+ }
+}
+
+class RedirectAuthRequest extends ButtonAuthenticationRequest {
+ public function __construct( \Message $label ) {
+ parent::__construct(
+ RedirectAuthProvider::BUTTON_NAME,
+ $label,
+ wfMessage(''), // the help message doesn't actually seem to be used anywhere?
+ true
+ );
+ }
+}
+
+class ReturnAuthRequest extends AuthenticationRequest {
+ public $query;
+
+ public function getFieldInfo() {
+ return [
+ 'query' => ['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 ($req instanceof OtherUsernameAuthRequest) {
+ if ($user == null) {
+ return AuthenticationResponse::newUI([new OtherUsernameAuthRequest($userInfo)], WfMessage('redirectauth-pick-other-username-invalid'));
+ } else if ($user->isRegistered()) {
+ return AuthenticationResponse::newUI([new OtherUsernameAuthRequest($userInfo)], WfMessage('redirectauth-pick-other-username-taken', $userInfo->userName));
+ }
+ }
+ if ($user == null) {
+ return AuthenticationResponse::newFail(wfMessage('redirectauth-error-invalid-username', 'ProviderDetails'));
+ }
+
+ if ($user->isRegistered()) {
+ // namespace collision
+
+ // 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'
+ ]
+ ];
+ }
+}
+
+/** 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);
+ }
+}