diff options
-rw-r--r-- | LICENSE | 21 | ||||
-rw-r--r-- | README.md | 41 | ||||
-rw-r--r-- | RedirectAuth.php | 562 | ||||
-rw-r--r-- | extension.json | 37 | ||||
-rw-r--r-- | i18n/en.json | 23 | ||||
-rw-r--r-- | schema.sql | 8 |
6 files changed, 692 insertions, 0 deletions
@@ -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. |