summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Fischer <martin@push-f.com>2022-10-09 18:56:04 +0200
committerMartin Fischer <martin@push-f.com>2022-10-13 00:34:59 +0200
commit4a612d49e20543cdb1094134a0ddb7f804135594 (patch)
tree3aa312785f0520869d6b849cdf29ccb2dbfe532f
initial commit
-rw-r--r--LICENSE21
-rw-r--r--README.md41
-rw-r--r--RedirectAuth.php562
-rw-r--r--extension.json37
-rw-r--r--i18n/en.json23
-rw-r--r--schema.sql8
6 files changed, 692 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d0099f3
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2022 Martin Fischer
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b598e19
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# RedirectAuth
+
+A MediaWiki extension to enable authentication via an external identity provider.
+Note that this extension does not implement any authentication protocol
+but instead provides a simple interface for the extension user to do so.
+
+The extension is [documented on mediawiki.org](https://www.mediawiki.org/wiki/Extension:RedirectAuth).
+
+## Design
+
+This extension was specifically designed for the scenario that a wiki
+already has users using MediaWiki's default authentication mechanism of
+(username, passsword) credentials.
+
+1. Users who don't yet have a wiki account can log in through the
+ external identity provider and create their wiki account with the
+ click of a button.
+2. Users who already have a wiki account can link it with their external
+ account and then use it for logging in going forward.
+
+The extension maintains its own mapping of external user identifiers to
+local user ids (allowing accounts to be linked even when they have
+different usernames). The extension enforces the mapping to be 1:1,
+meaning a wiki account can only be linked to one external identity and
+one external identity can only be linked to one wiki account. Care has
+been taken that existing wiki accounts cannot be maliciously overtaken
+via the 3rd party login (existing wiki accounts can only be linked to an
+external account if you're already logged in as the wiki account).
+
+## Non-goals
+
+* supporting more than one external identity provider per wiki
+* supporting any authentication protocols out of the box
+
+## Credits
+
+Thanks to Florian Schmidt for his [Extension:GoogleLogin] extension.
+Looking at its source code helped me understand MediaWiki's
+`PrimaryAuthenticationProvider` API.
+
+[Extension:GoogleLogin]: https://www.mediawiki.org/wiki/Extension:GoogleLogin
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);
+ }
+}
diff --git a/extension.json b/extension.json
new file mode 100644
index 0000000..7013ee0
--- /dev/null
+++ b/extension.json
@@ -0,0 +1,37 @@
+{
+ "name": "RedirectAuth",
+ "type": "other",
+ "license-name": "MIT",
+ "author": "[https://push-f.com/ Martin Fischer]",
+ "url": "https://git.push-f.com/mw-redirect-auth",
+ "version": "0.1.0",
+ "description": "Log in via an external identity provider.",
+ "MessagesDirs": {
+ "VoteExtension": [
+ "i18n"
+ ]
+ },
+ "AutoloadClasses": {
+ "MediaWiki\\Extension\\RedirectAuth\\ProviderDetails": "RedirectAuth.php",
+ "MediaWiki\\Extension\\RedirectAuth\\UserInfo": "RedirectAuth.php",
+ "MediaWiki\\Extension\\RedirectAuth\\RedirectAuthProvider": "RedirectAuth.php",
+ "MediaWiki\\Extension\\RedirectAuth\\SpecialRedirectAuthReturn": "RedirectAuth.php"
+ },
+ "AuthManagerAutoConfig": {
+ "primaryauth": {
+ "RedirectAuthProvider": {
+ "class": "MediaWiki\\Extension\\RedirectAuth\\RedirectAuthProvider"
+ }
+ }
+ },
+ "Hooks": {
+ "LoadExtensionSchemaUpdates": "MediaWiki\\Extension\\RedirectAuth\\Hooks::onLoadExtensionSchemaUpdates",
+ "AuthChangeFormFields": "MediaWiki\\Extension\\RedirectAuth\\Hooks::onAuthChangeFormFields",
+ "GetPreferences": "MediaWiki\\Extension\\RedirectAuth\\Hooks::onGetPreferences",
+ "SpecialStatsAddExtra": "MediaWiki\\Extension\\RedirectAuth\\Hooks::onSpecialStatsAddExtra"
+ },
+ "SpecialPages": {
+ "RedirectAuthReturn": "MediaWiki\\Extension\\RedirectAuth\\SpecialRedirectAuthReturn"
+ },
+ "manifest_version": 1
+}
diff --git a/i18n/en.json b/i18n/en.json
new file mode 100644
index 0000000..7e5f74a
--- /dev/null
+++ b/i18n/en.json
@@ -0,0 +1,23 @@
+{
+ "redirectauth-log-in-with": "Log in with $1",
+ "redirectauth-try-again": "This shouldn't be happening. Maybe try again?",
+ "redirectauth-auth-failed": "Authentication failed: $1",
+ "redirectauth-collision": "There already exists a wiki account with the name $1. Does this wiki account belong to you?",
+ "redirectauth-collision-opt-yes": "Yes, this is my wiki account.",
+ "redirectauth-collision-opt-no": "No, this is not my wiki account.",
+ "redirectauth-create-or-log-in": "Welcome to the wiki!",
+ "redirectauth-opt-create": "Create wiki account named $1",
+ "redirectauth-opt-log-in": "I already have an account for this wiki and want to link it to my $1 account",
+ "redirectauth-collision-opt-mine": "That is my account",
+ "redirectauth-collision-opt-log-in": "I already have a different account",
+ "redirectauth-collision-opt-create": "Create a new differently named account",
+ "redirectauth-pick-other-username-taken": "The username $1 is already taken, please choose another username",
+ "redirectauth-pick-other-username-invalid": "That username is invalid, please choose another username",
+ "redirectauth-error-invalid-username": "The configured $1 returned an invalid username",
+ "redirectauth-linked-account": "Linked account:",
+ "redirectauth-link-your": "Link your $1 account",
+ "redirectauth-unlink-your": "Unlink your $1 account",
+ "redirectauth-error-already-linked": "Linking failed because this $1 account is already linked to the wiki user $2. An $1 account can only be linked to one wiki user at a time.",
+ "redirectauth-count": "Linked accounts",
+ "redirectauth-unlink-account": "Unlink account"
+}
diff --git a/schema.sql b/schema.sql
new file mode 100644
index 0000000..1720bfc
--- /dev/null
+++ b/schema.sql
@@ -0,0 +1,8 @@
+CREATE TABLE redirectauth_mappings (
+ local_id int NOT NULL UNIQUE,
+ external_id varchar(255) NOT NULL UNIQUE,
+ PRIMARY KEY (local_id, external_id)
+);
+-- We don't define a foreign key from local_id to the user table because doing so
+-- is difficult in a manner that works with both MySQL and PostgreSQL, so we'd
+-- have to maintain this schema in multiple dialects, which just isn't worth it.