CSteipp has submitted this change and it was merged. Change subject: Initial WMF extension commit ......................................................................
Initial WMF extension commit Moved from https://github.com/Stype/OAuthAuthentication.git Change-Id: I8a51690828ab09c7e49fa53a8c46497c861f58e3 --- A LICENSE A OAuthAuthentication.notranslate-alias.php A OAuthAuthentication.php A README.md A TODO.txt A handlers/AuthenticationHandler.php A handlers/OAuth1Handler.php A i18n/en.json A i18n/qqq.json A libs/mwoauth-php/MWOAuthClient.php A libs/mwoauth-php/OAuth.php A specials/SpecialOAuthLogin.php A store/PhpSessionStore.php A store/SessionStore.php A store/oauthauth.sql A tests/OAuthAuthConfigTest.php A tests/OAuthAuthDBTest.php A tests/OAuthAuthExternalUserTest.php A tests/OAuthAuthHooksTest.php A utils/Config.php A utils/Exception.php A utils/Hooks.php A utils/OAuthExternalUser.php A utils/Policy.php 24 files changed, 2,617 insertions(+), 0 deletions(-) Approvals: CSteipp: Verified; Looks good to me, approved diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d7f1051 --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ +GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., <http://fsf.org/> + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {description} + Copyright (C) {year} {fullname} + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + {signature of Ty Coon}, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/OAuthAuthentication.notranslate-alias.php b/OAuthAuthentication.notranslate-alias.php new file mode 100644 index 0000000..03e9113 --- /dev/null +++ b/OAuthAuthentication.notranslate-alias.php @@ -0,0 +1,21 @@ +<?php +/** + * Aliases for special page which the user shouldn't access directly, so + * no need to translate (and translation will hurt the cache). + * + * Do not add this file to translatewiki. + * + * @file + * @ingroup Extensions + */ +// @codingStandardsIgnoreFile + +$specialPageAliases = array(); + +/** English (English) */ +$specialPageAliases['en'] = array( + // Localizing Special:CentralAutoLogin causes issues (bug 54195) and is of + // miniscule benefit to users, so don't do so. + 'OAuthLogin' => array( 'OAuthLogin' ), +); + diff --git a/OAuthAuthentication.php b/OAuthAuthentication.php new file mode 100644 index 0000000..a0e68d6 --- /dev/null +++ b/OAuthAuthentication.php @@ -0,0 +1,134 @@ +<?php + +namespace MediaWiki\Extensions\OAuthAuthentication; + +if ( !defined( 'MEDIAWIKI' ) ) { + echo "OAuth extension\n"; + exit( 1 ) ; +} + +$wgExtensionCredits['other'][] = array( + 'path' => __FILE__, + 'name' => 'OAuthAuthentication', + 'description' => 'This extension allows wikis to delegate their authentication to anther wiki', + 'author' => array( 'Chris Steipp' ), + 'url' => 'https://www.mediawiki.org/wiki/Extension:OAuthAuthentication', + 'version' => '0.1.0' +); + +/** + * Must be configured in LocalSettings.php! + * The OAuth special page on the wiki. Passing the title as a parameter + * is usually more reliable E.g., http://en.wikipedia.org/w/index.php?title=Special:OAuth + */ +$wgOAuthAuthenticationUrl = null; + +/** + * Must be configured in LocalSettings.php! + * The Key and Secret that were generated for you when you registered + * your consumer. RSA private key isn't currently supported. + */ +$wgOAuthAuthenticationConsumerKey = null; +$wgOAuthAuthenticationConsumerSecret = null; + +/** + * Optionally set the Canonical url that the server will return, + * if it's different from the OAuth endpoint. OAuth will use + * wgCannonicalServer when generating the identity JWT, and this + * code will compare the iss to this value, or $wgOAuthAuthenticationUrl + * if this isn't set. + */ +$wgOAuthAuthenticationCanonicalUrl = null; + +/** + * Allow usurpation of accounts. If accounts on the OAuth provider have the same + * name as an already created local account, this flag decides if the user is allowed + * to login, or if the login will fail with an error message. + */ +$wgOAuthAuthenticationAccountUsurpation = false; + +/** + * Only allow creation/login of usernames that are on a whitelist. Setting this to + * false allows any username to register and login. + */ +$wgOAuthAuthenticationUsernameWhitelist = false; + +/** + * Only allow creation/login of users who are in groups on the remote wiki. Setting + * this to false allows any username to register and login. + */ +$wgOAuthAuthenticationGroupWhitelist = false; + +/** + * Allow local account creation. Set this to false if you only want + * to use remote accounts. + * Note: Once local accounts exist, this extension will not prevent + * them from logging in. + */ +$wgOAuthAuthenticationAllowLocalUsers = true; + +/** + * A simple text string, naming the remote wiki (used for text like, "Login on <wikiname>". If + * this is false, a generic "Remote OAuth Wiki" is used, which users may not understand. + */ +$wgOAuthAuthenticationRemoteName = false; + +/** + * Max age that a session can go without re-validating the user's identity. + */ +$wgOAuthAuthenticationMaxIdentityAge = 3600; + +/** + * If $wgOAuthAuthenticationUrl uses https, do we validate the certificate? + * This should always be true in production, but sometimes useful to disable + * while testing. + */ +$wgOAuthAuthenticationValidateSSL = true; + +$dir = __DIR__; +$wgAutoloadClasses['MediaWiki\Extensions\OAuthAuthentication\SpecialOAuthLogin'] = "$dir/specials/SpecialOAuthLogin.php"; +$wgAutoloadClasses['MediaWiki\Extensions\OAuthAuthentication\Config'] = "$dir/utils/Config.php"; +$wgAutoloadClasses['MediaWiki\Extensions\OAuthAuthentication\Exception'] = "$dir/utils/Exception.php"; +$wgAutoloadClasses['MediaWiki\Extensions\OAuthAuthentication\Hooks'] = "$dir/utils/Hooks.php"; +$wgAutoloadClasses['MediaWiki\Extensions\OAuthAuthentication\Policy'] = "$dir/utils/Policy.php"; +$wgAutoloadClasses['MediaWiki\Extensions\OAuthAuthentication\OAuthExternalUser'] = "$dir/utils/OAuthExternalUser.php"; +$wgAutoloadClasses['MediaWiki\Extensions\OAuthAuthentication\AuthenticationHandler'] = "$dir/handlers/AuthenticationHandler.php"; +$wgAutoloadClasses['MediaWiki\Extensions\OAuthAuthentication\OAuth1Handler'] = "$dir/handlers/OAuth1Handler.php"; +$wgAutoloadClasses['MediaWiki\Extensions\OAuthAuthentication\SessionStore'] = "$dir/store/SessionStore.php"; +$wgAutoloadClasses['MediaWiki\Extensions\OAuthAuthentication\PhpSessionStore'] = "$dir/store/PhpSessionStore.php"; + +## i18n +$wgMessagesDirs['OAuthAuthentication'] = "$dir/i18n"; +#$messagesFiles['OAuthAuthentication'] = "$langDir/OAuthAuthentication.alias.php"; +$wgExtensionMessagesFiles['SpecialOAuthLoginNoTranslate'] = "$dir/OAuthAuthentication.notranslate-alias.php"; + + + +## Use mwoauth-php. Cool Kids can use composer to do this. +$wgAutoloadClasses['MWOAuthClientConfig'] = "$dir/libs/mwoauth-php/MWOAuthClient.php"; +$wgAutoloadClasses['MWOAuthClient'] = "$dir/libs/mwoauth-php/MWOAuthClient.php"; +$wgAutoloadClasses['OAuthToken'] = "$dir/libs/mwoauth-php/OAuth.php"; + + +$wgSpecialPages['OAuthLogin'] = 'MediaWiki\Extensions\OAuthAuthentication\SpecialOAuthLogin'; + +$wgHooks['PersonalUrls'][] = 'MediaWiki\Extensions\OAuthAuthentication\Hooks::onPersonalUrls'; +$wgHooks['PostLoginRedirect'][] = 'MediaWiki\Extensions\OAuthAuthentication\Hooks::onPostLoginRedirect'; +$wgHooks['LoadExtensionSchemaUpdates'][] = 'MediaWiki\Extensions\OAuthAuthentication\Hooks::onLoadExtensionSchemaUpdates'; +$wgHooks['GetPreferences'][] = 'MediaWiki\Extensions\OAuthAuthentication\Hooks::onGetPreferences'; +$wgHooks['AbortNewAccount'][] = 'MediaWiki\Extensions\OAuthAuthentication\Hooks::onAbortNewAccount'; +$wgHooks['UserLoadAfterLoadFromSession'][] = 'MediaWiki\Extensions\OAuthAuthentication\Hooks::onUserLoadAfterLoadFromSession'; + +$wgHooks['UnitTestsList'][] = function( array &$files ) { + $directoryIterator = new \RecursiveDirectoryIterator( __DIR__ . '/tests/' ); + foreach ( new \RecursiveIteratorIterator( $directoryIterator ) as $fileInfo ) { + if ( substr( $fileInfo->getFilename(), -8 ) === 'Test.php' ) { + $files[] = $fileInfo->getPathname(); + } + } + return true; +}; + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b7f210 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +OAuthAuthentication +=================== + +MediaWiki OAuth Authentication client diff --git a/TODO.txt b/TODO.txt new file mode 100644 index 0000000..b6dbd9b --- /dev/null +++ b/TODO.txt @@ -0,0 +1,24 @@ + +* Move Utils -> Hoos {{done}} +** Add DB update hook {{done}} +* Prefs {{done}} +* Configuration {{done}} +* i18n {{done}} +* github {{done}} +* Usurpation {{done}} +* Whitelists {{done}} +* Debugs {{done}} +* Config flag to disallow local creates {{done}} +** remove create account link in toolbar {{done}} +* max session without revalidation when whitelisted {{done}} + +* Tests +** Refactor special +*** Divide finish handling for flow steps +*** Location from Special page + +* comments / docs + +* Reorg directories? + +* Change returnto url when clicking login on Special:UserLogout diff --git a/handlers/AuthenticationHandler.php b/handlers/AuthenticationHandler.php new file mode 100644 index 0000000..c701d47 --- /dev/null +++ b/handlers/AuthenticationHandler.php @@ -0,0 +1,121 @@ +<?php + +namespace MediaWiki\Extensions\OAuthAuthentication; + +class AuthenticationHandler { + + + public static function handleIdentity( \WebRequest $request, $identity, \OAuthToken $accessToken ) { + $exUser = OAuthExternalUser::newFromRemoteId( + $identity->sub, + $identity->username, + wfGetDB( DB_MASTER ) #TODO: don't do this + ); + $exUser->setAccessToken( $accessToken ); + $exUser->setIdentifyTS( new \MWTimestamp() ); + + if ( $exUser->attached() ) { + $status = AuthenticationHandler::doLogin( $exUser, $request ); + $s = \Status::newGood( array( 'successfulLogin', $status->getValue() ) ); + $s->merge( $status ); + } else { + $status = AuthenticationHandler::doCreateAndLogin( $exUser, $request ); + $s = \Status::newGood( array( 'successfulCreation', $status->getValue() ) ); + $s->merge( $status ); + } + + wfDebugLog( "OAuthAuth", __METHOD__ . " returning Status: " . (int) $s->isGood() ); + return $s; + } + + public static function doCreateAndLogin( OAuthExternalUser $exUser ) { + global $wgAuth, $wgOAuthAuthenticationAccountUsurpation; + wfDebugLog( "OAuthAuth", "Doing create & login for user " . $exUser->getName() ); + + $u = \User::newFromName( $exUser->getName(), 'creatable' ); + + if ( !is_object( $u ) ) { + wfDebugLog( "OAuthAuth", + __METHOD__ . ": Bad username '{$exUser->getName()}'" ); + return Status::newFatal( 'oauthauth-create-noname' ); + } elseif ( 0 !== $u->idForName() ) { + wfDebugLog( "OAuthAuth", + __METHOD__ . ": User already exists, but no usurpation. Aborting." ); + if ( !$wgOAuthAuthenticationAccountUsurpation ) { + return \Status::newFatal( 'oauthauth-create-userexists' ); + } + $exUser->setLocalId( $u->idForName() ); + } else { + wfDebugLog( "OAuthAuth", + __METHOD__ . ": Creating user '{$exUser->getName()}'" ); + + # TODO: Does this need to call $wgAuth->addUser? This could potentially coexist + # with another auth plugin. + + $status = $u->addToDatabase(); + if ( !$status->isOK() ) { + return $status; + } + + /* TODO: Set email, realname, and language, once we can get them via /identify + $u->setEmail( $exUser->getEmail() ); + $u->setRealName( $exUser->getRealName() ); + $u->setOption( 'language', $exUser->getLanguage() ); + */ + + $u->setToken(); + \DeferredUpdates::addUpdate( new \SiteStatsUpdate( 0, 0, 0, 0, 1 ) ); + $u->addWatch( $u->getUserPage(), \WatchedItem::IGNORE_USER_RIGHTS ); + $u->saveSettings(); + + wfRunHooks( 'AddNewAccount', array( $u, false ) ); + + $exUser->setLocalId( $u->getId() ); + } + + $exUser->addToDatabase( wfGetDB( DB_MASTER ) ); //TODO: di + $u->setCookies(); + $u->addNewUserLogEntry( 'create' ); + + wfResetSessionID(); + + return \Status::newGood( $u ); + } + + + public static function doLogin( OAuthExternalUser $exUser, \WebRequest $request ) { + global $wgSecureLogin, $wgCookieSecure; + + wfDebugLog( "OAuthAuth", + __METHOD__ . ": Logging in associated user '{$exUser->getName()}'" ); + + $u = \User::newFromId( $exUser->getLocalId() ); + + if ( !is_object( $u ) ) { + wfDebugLog( "OAuthAuth", + __METHOD__ . ": Associated user doesn't exist. Aborting." ); + return Status::newFatal( 'oauthauth-login-noname' ); + } elseif ( $u->isAnon() ) { + wfDebugLog( "OAuthAuth", + __METHOD__ . ": Associated user is Anon. Aborting." ); + return \Status::newFatal( 'oauthauth-login-usernotexists' ); + } +wfDebugLog( "OAA", __METHOD__ . " updating exuser: " . print_r( $exUser, true ) ); + $exUser->updateInDatabase( wfGetDB( DB_MASTER ) ); + + $u->invalidateCache(); + + if ( !$wgSecureLogin ) { + $u->setCookies( $request, null ); + } elseif ( $u->requiresHTTPS() ) { + $u->setCookies( $request, true ); + } else { + $u->setCookies( $request, false ); + $wgCookieSecure = false; + } + + wfResetSessionID(); + + return \Status::newGood( $u ); + } +} diff --git a/handlers/OAuth1Handler.php b/handlers/OAuth1Handler.php new file mode 100644 index 0000000..7027bf9 --- /dev/null +++ b/handlers/OAuth1Handler.php @@ -0,0 +1,53 @@ +<?php + +namespace MediaWiki\Extensions\OAuthAuthentication; + +class OAuth1Handler { + + + public function init( SessionStore $session, $client ) { + // Step 1 - Get a request token + list( $redir, $requestToken ) = $client->initiate(); + $session->set( 'oauthreqtoken', "{$requestToken->key}:{$requestToken->secret}" ); + return $redir; + } + + public function authorize( \WebResponse $response, $url ) { + $response->header( "Location: $url", true ); + } + + + public function finish( \WebRequest $request, SessionStore $session, $client ) { + $verifyCode = $request->getVal( 'oauth_verifier', false ); + $recKey = $request->getVal( 'oauth_token', false ); + + if ( !$verifyCode || ! $recKey ) { + throw new Exception( 'oauthauth-failed-handshake' ); + } + + list( $requestKey, $requestSecret ) = explode( ':', $session->get( 'oauthreqtoken' ) ); + $requestToken = new \OAuthToken( $requestKey, $requestSecret ); + + $session->delete( 'oauthreqtoken' ); + + //check for csrf + if ( $requestKey !== $recKey ) { + throw new Exception( "oauthauth-csrf-detected" ); + } + + // Step 3 - Get access token + $accessToken = $client->complete( $requestToken, $verifyCode ); + + return $accessToken; + } + + + public function identify( \OAuthToken $accessToken, $client ) { + // Get Identity + $identity = $client->identify( $accessToken ); + + return $identity; + } + + +} diff --git a/i18n/en.json b/i18n/en.json new file mode 100644 index 0000000..460f488 --- /dev/null +++ b/i18n/en.json @@ -0,0 +1,20 @@ +{ + "@metadata": { + "authors": [ + "CSteipp" + ] + }, + "oauthauth-already-logged-in": "You are already logged in. $1 to continue", + "oauthauth-error": "OAuth Login Error", + "ouathauth-logout": "Logout", + "oauthauth-failed-handshake": "There was an OAuth protocol error logging you in. The remote wiki may be experiencing technical issues", + "oauthauth-csrf-detected": "OAuth session mismatch. Please try logging in again.", + "oauthauth-create-noname": "OAuth error logging you in. The username on the remote wiki isn't valid on this wiki.", + "oauthauth-create-userexists": "OAuth error logging you in. The username on the remote wiki already exists on this wiki. You must contact a bureaucrat to usurp the account.", + "oauthauth-login-noname": "The OAuth user is connected, but the name isn't valid on this wiki.", + "oauthauth-login": "Log in on $1", + "oauthauth-login-usernotexists": "The OAuth user listed as connected, but the user doesn't exist on this wiki", + "oauthauth-nologin-policy": "This wiki's policy will not let you login.", + "oauthauth-localuser-not-allowed": "The site administrator has disabled local account creation. You should $1 to create an account with OAuth.", + "oauthauth-loggout-policy": "You have been logged out because the site policy no longer allows you to be logged in." +} diff --git a/i18n/qqq.json b/i18n/qqq.json new file mode 100644 index 0000000..d4c8872 --- /dev/null +++ b/i18n/qqq.json @@ -0,0 +1,20 @@ +{ + "@metadata": { + "authors": [ + "CSteipp" + ] + }, + "oauthauth-already-logged-in": "You are already logged in. $1 to continue", + "oauthauth-error": "Title for OAuth login page errors", + "ouathauth-logout": "Link text for logout link", + "oauthauth-failed-handshake": "Error message to users when an unknown issue occured.", + "oauthauth-csrf-detected": "Error when anti-csrf tokens do not match, and the user needs to login again.", + "oauthauth-create-noname": "Error when the username on the OAuth-server wiki isn't valid on this client wiki.", + "oauthauth-create-userexists": "Error when the login account is already created on the client wiki, preventing the user from logging in.", + "oauthauth-login-noname": "Error when the username on the OAuth-server wiki isn't valid on this client wiki.", + "oauthauth-login": "Text for link to login on remote wiki.\n\nParameters:\n* $1 - the name of the remote wiki where the user will log in.", + "oauthauth-login-usernotexists": "Error when the user logs in with an account that was attached on this wiki, but no longer exists.", + "oauthauth-nologin-policy": "Error the user sees hwne the local wiki's administrator has prevented their login with a policy.", + "oauthauth-localuser-not-allowed": "Error when the user attempts to create an account, but the wiki isn't configured to allow it, and instructing the user to login via OAuth.\n\nParameters:\n* $1 - link text {{msg-mw|login}}. A link to login.", + "oauthauth-loggout-policy": "Error message when a user is logged out because their user no longer complies with the requirements set by the site administrator" +} diff --git a/libs/mwoauth-php/MWOAuthClient.php b/libs/mwoauth-php/MWOAuthClient.php new file mode 100644 index 0000000..b116ee1 --- /dev/null +++ b/libs/mwoauth-php/MWOAuthClient.php @@ -0,0 +1,288 @@ +<?php + +include_once 'OAuth.php'; // reference php library from oauth.net + +if ( !function_exists( 'wfDebugLog' ) ) { + function wfDebugLog( $method, $msg) { + // Uncomment this if you want debuggging info from the OAuth library + //echo "[$method] $msg\n"; + } +} + + +class MWOAuthClientConfig { + + // Url to the OAuth special page + public $endpointURL; + + // Canonical server url, used to check /identify's iss + public $canonicalServerUrl; + + // Url that the user is sent to. Can be different from + // $endpointURL to play nice with MobileFrontend, etc. + public $redirURL = null; + + // Use https when calling the server. + // TODO: detect this from $endpointURL + public $useSSL = true; + + // If you're testing against a server with self-signed certificates, you + // can turn this off but don't do this in production. + public $verifySSL = true; + + function __construct( $url, $useSSL, $verifySSL ) { + $this->endpointURL = $url; + $this->useSSL = $useSSL; + $this->verifySSL = $verifySSL; + } + +} + +class MWOAuthClient { + + // MWOAuthClientConfig + private $config; + + // TODO: move this to $config + private $consumerToken; + + // Any extra params in the call that need to be signed + private $extraParams = array(); + + // Track the last random nonce generated by the OAuth lib, used to + // verify /identity response isn't a replay + private $lastNonce; + + function __construct( MWOAuthClientConfig $config, OAuthToken $cmrToken ) { + $this->consumerToken = $cmrToken; + $this->config = $config; + } + + + public static function newFromKeyAndSecret( $url, $key, $secret ) { + $cmrToken = new OAuthToken( $key, $secret ); + $config = new MWOAuthClientConfig( $url, true, true ); + return new self( $config, $cmrToken ); + } + + public function setExtraParam( $key, $value ) { + $this->extraParams[$key] = $value; + } + + public function setExtraParams( $params ) { + $this->extraParams = $params; + } + + /** + * First part of 3-legged OAuth, get the request Token. + * Redirect your authorizing users to the redirect url, and keep + * track of the request token since you need to pass it into complete() + * + * @return array (redirect, request/temp token) + */ + public function initiate() { + $initUrl = $this->config->endpointURL . '/initiate&format=json&oauth_callback=oob'; + $data = $this->makeOAuthCall( null, $initUrl ); + $return = json_decode( $data ); + if ( $return->oauth_callback_confirmed !== 'true' ) { + throw new Exception( "Callback wasn't confirmed" ); + } + $requestToken = new OAuthToken( $return->key, $return->secret ); + $url = $this->config->redirURL ?: $this->config->endpointURL . "/authorize&"; + $url .= "oauth_token={$requestToken->key}&oauth_consumer_key={$this->consumerToken->key}"; + + return array( $url, $requestToken ); + } + + /** + * The final leg of the OAuth handshake. Exchange the request Token from + * initiate() and the verification code that the user submitted back to you + * for an access token, which you'll use for all API calls. + * + * @param the authorization code sent to the callback url + * @param the temp/request token obtained from initiate, or null if this + * object was used and the token is already set. + * @return OAuthToken The access token + */ + public function complete( OAuthToken $requestToken, $verifyCode ) { + $tokenUrl = $this->config->endpointURL . '/token&format=json'; + $this->setExtraParam( 'oauth_verifier', $verifyCode ); + $data = $this->makeOAuthCall( $requestToken , $tokenUrl ); + $return = json_decode( $data ); + $accessToken = new OAuthToken( $return->key, $return->secret ); + $this->setExtraParams = array(); // cleanup after ourselves + return $accessToken; + } + + + /** + * Optional step. This call the MediaWiki specific /identify method, which + * returns a signed statement of the authorizing user's identity. Use this + * if you are authenticating users in your application, and you need to + * know their username, groups, rights, etc in MediaWiki. + * + * @param OAuthToken access token from complete() + * @return object containing attributes of the user + */ + public function identify( OAuthToken $accessToken ) { + $identifyUrl = $this->config->endpointURL . '/identify'; + $data = $this->makeOAuthCall( $accessToken, $identifyUrl ); + $identity = $this->decodeJWT( $data, $this->consumerToken->secret ); + + if ( !$this->validateJWT( + $identity, + $this->consumerToken->key, + $this->config->canonicalServerUrl, + $this->lastNonce + ) ) { + throw new Exception( "JWT didn't validate" ); + } + + return $identity; + } + + /** + * Make a signed request to MediaWiki + * + * @param OAuthToken $token additional token to use in signature, besides the consumer token. + * In most cases, this will be the access token you got from complete(), but we set it + * to the request token when finishing the handshake. + * @param $url string url to call + * @param $isPost bool true if this should be a POST request + * @param $postFields array of POST parameters, only if $isPost is also true + * @return body from the curl request + */ + public function makeOAuthCall( $token, $url, $isPost = false, $postFields = false ) { + + $params = array(); + + // Get any params from the url + if ( strpos( $url, '?' ) ) { + $parsed = parse_url( $url ); + parse_str($parsed['query'], $params); + } + $params += $this->extraParams; + + $method = $isPost ? 'POST' : 'GET'; + $req = OAuthRequest::from_consumer_and_token( + $this->consumerToken, + $token, + $method, + $url, + $params + ); + $req->sign_request( + new OAuthSignatureMethod_HMAC_SHA1(), + $this->consumerToken, + $token + ); + + $this->lastNonce = $req->get_parameter( 'oauth_nonce' ); + + return $this->makeCurlCall( + $url, + $req->to_header(), + $isPost, + $postFields, + $this->config + ); + + } + + + private function makeCurlCall( $url, $headers, $isPost, $postFields, MWOAuthClientConfig $config ) { + + $ch = curl_init(); + curl_setopt( $ch, CURLOPT_URL, (string) $url ); + curl_setopt( $ch, CURLOPT_HEADER, 0 ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 ); + curl_setopt( $ch, CURLOPT_HTTPHEADER, array( $headers ) ); + + if ( $isPost ) { + curl_setopt( $ch, CURLOPT_POST, true ); + curl_setopt( $ch, CURLOPT_POSTFIELDS, http_build_query( $postFields ) ); + } + + if ( $config->useSSL ) { + curl_setopt( $ch, CURLOPT_PORT , 443 ); + } + + if ( $config->verifySSL ) { + curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 2 ); + } else { + curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false ); + curl_setopt( $ch, CURLOPT_SSL_VERIFYHOST, 0 ); + } + + $data = curl_exec( $ch ); + if( !$data ) { + throw new Exception ( 'Curl error: ' . curl_error( $ch ) ); + } + + return $data; + } + + + private function decodeJWT( $JWT, $secret ) { + list( $headb64, $bodyb64, $sigb64 ) = explode( '.', $JWT ); + + $header = json_decode( $this->urlsafeB64Decode( $headb64 ) ); + $payload = json_decode( $this->urlsafeB64Decode( $bodyb64 ) ); + $sig = $this->urlsafeB64Decode( $sigb64 ); + + // MediaWiki will only use sha256 hmac (HS256) for now. This check makes sure + // an attacker doesn't return a JWT with 'none' signature type. + $expectSig = hash_hmac( 'sha256', "$headb64.$bodyb64", $secret, true); + if ( $header->alg !== 'HS256' || !$this->compareHash( $sig, $expectSig ) ) { + throw new Exception( "Invalid JWT signature from /identify." ); + } + return $payload; + } + + protected function validateJWT( $identity, $consumerKey, $expectedConnonicalServer, $nonce ) { + // Verify the issuer is who we expect (server sends $wgCanonicalServer) + if ( $identity->iss !== $expectedConnonicalServer ) { + print "Invalid Issuer: {$identity->iss} !== {$expectedConnonicalServer}"; + return false; + } + // Verify we are the intended audience + if ( $identity->aud !== $consumerKey ) { + print "Invalid Audience"; + return false; + } + // Verify we are within the time limits of the token. Issued at (iat) should be + // in the past, Expiration (exp) should be in the future. + $now = time(); + if ( $identity->iat > $now || $identity->exp < $now ) { + print "Invalid Time"; + return false; + } + // Verify we haven't seen this nonce before, which would indicate a replay attack + if ( $identity->nonce !== $nonce ) { + print "Invalid Nonce"; + return false; + } + return true; + } + + private function urlsafeB64Decode( $input ) { + $remainder = strlen( $input ) % 4; + if ( $remainder ) { + $padlen = 4 - $remainder; + $input .= str_repeat( '=', $padlen ); + } + return base64_decode( strtr( $input, '-_', '+/' ) ); + } + + // Constant time comparison + private function compareHash( $hash1, $hash2 ) { + $result = strlen( $hash1 ) ^ strlen( $hash2 ); + $len = min( strlen( $hash1 ), strlen( $hash2 ) ) - 1; + for ( $i = 0; $i < $len; $i++ ) { + $result |= ord( $hash1{$i} ) ^ ord( $hash2{$i} ); + } + return $result == 0; + } + +} diff --git a/libs/mwoauth-php/OAuth.php b/libs/mwoauth-php/OAuth.php new file mode 100644 index 0000000..2856003 --- /dev/null +++ b/libs/mwoauth-php/OAuth.php @@ -0,0 +1,925 @@ +<?php +// vim: foldmethod=marker +/** + * The MIT License + * + * Copyright (c) 2007 Andy Smith + * + * 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. +*/ +/* Generic exception class + */ +class OAuthException extends Exception { + // pass +} + +class OAuthConsumer { + public $key; + public $secret; + public $callback_url; + + function __construct( $key, $secret, $callback_url = NULL ) { + $this->key = $key; + $this->secret = $secret; + $this->callback_url = $callback_url; + } + + function __toString() { + return "OAuthConsumer[key=$this->key,secret=$this->secret]"; + } +} + +class OAuthToken { + // access tokens and request tokens + public $key; + public $secret; + + /** + * key = the token + * secret = the token secret + */ + function __construct( $key, $secret ) { + $this->key = $key; + $this->secret = $secret; + } + + /** + * generates the basic string serialization of a token that a server + * would respond to request_token and access_token calls with + */ + function to_string() { + return "oauth_token=" . + OAuthUtil::urlencode_rfc3986( $this->key ) . + "&oauth_token_secret=" . + OAuthUtil::urlencode_rfc3986( $this->secret ); + } + + function __toString() { + return $this->to_string(); + } +} + +/** + * A class for implementing a Signature Method + * See section 9 ( "Signing Requests" ) in the spec + */ +abstract class OAuthSignatureMethod { + /** + * Needs to return the name of the Signature Method ( ie HMAC-SHA1 ) + * @return string + */ + abstract public function get_name(); + + /** + * Build up the signature + * NOTE: The output of this function MUST NOT be urlencoded. + * the encoding is handled in OAuthRequest when the final + * request is serialized + * @param OAuthRequest $request + * @param OAuthConsumer $consumer + * @param OAuthToken $token + * @return string + */ + abstract public function build_signature( $request, $consumer, $token ); + + /** + * Verifies that a given signature is correct + * @param OAuthRequest $request + * @param OAuthConsumer $consumer + * @param OAuthToken $token + * @param string $signature + * @return bool + */ + public function check_signature( $request, $consumer, $token, $signature ) { + wfDebugLog( 'OAuth', __METHOD__ . ": Expecting: '$signature'" ); + $built = $this->build_signature( $request, $consumer, $token ); + wfDebugLog( 'OAuth', __METHOD__ . ": Built: '$built'" ); + // Check for zero length, although unlikely here + if ( strlen( $built ) == 0 || strlen( $signature ) == 0 ) { + return false; + } + + if ( strlen( $built ) != strlen( $signature ) ) { + return false; + } + + // Avoid a timing leak with a ( hopefully ) time insensitive compare + $result = 0; + for ( $i = 0; $i < strlen( $signature ); $i++ ) { + $result |= ord( $built{$i} ) ^ ord( $signature{$i} ); + } + + return $result == 0; + } +} + +/** + * The HMAC-SHA1 signature method uses the HMAC-SHA1 signature algorithm as defined in [RFC2104] + * where the Signature Base String is the text and the key is the concatenated values ( each first + * encoded per Parameter Encoding ) of the Consumer Secret and Token Secret, separated by an '&' + * character ( ASCII code 38 ) even if empty. + * - Chapter 9.2 ( "HMAC-SHA1" ) + */ +class OAuthSignatureMethod_HMAC_SHA1 extends OAuthSignatureMethod { + function get_name() { + return "HMAC-SHA1"; + } + + public function build_signature( $request, $consumer, $token ) { + $base_string = $request->get_signature_base_string(); + wfDebugLog( 'OAuth', __METHOD__ . ": Base string: '$base_string'" ); + $request->base_string = $base_string; + + $key_parts = array( + $consumer->secret, + ( $token ) ? $token->secret : "" + ); + + $key_parts = OAuthUtil::urlencode_rfc3986( $key_parts ); + $key = implode( '&', $key_parts ); + wfDebugLog( 'OAuth', __METHOD__ . ": HMAC Key: '$key'" ); + return base64_encode( hash_hmac( 'sha1', $base_string, $key, true ) ); + } +} + +/** + * The PLAINTEXT method does not provide any security protection and SHOULD only be used + * over a secure channel such as HTTPS. It does not use the Signature Base String. + * - Chapter 9.4 ( "PLAINTEXT" ) + */ +class OAuthSignatureMethod_PLAINTEXT extends OAuthSignatureMethod { + public function get_name() { + return "PLAINTEXT"; + } + + /** + * oauth_signature is set to the concatenated encoded values of the Consumer Secret and + * Token Secret, separated by a '&' character ( ASCII code 38 ), even if either secret is + * empty. The result MUST be encoded again. + * - Chapter 9.4.1 ( "Generating Signatures" ) + * + * Please note that the second encoding MUST NOT happen in the SignatureMethod, as + * OAuthRequest handles this! + */ + public function build_signature( $request, $consumer, $token ) { + $key_parts = array( + $consumer->secret, + ( $token ) ? $token->secret : "" + ); + + $key_parts = OAuthUtil::urlencode_rfc3986( $key_parts ); + $key = implode( '&', $key_parts ); + $request->base_string = $key; + + return $key; + } +} + +/** + * The RSA-SHA1 signature method uses the RSASSA-PKCS1-v1_5 signature algorithm as defined in + * [RFC3447] section 8.2 ( more simply known as PKCS#1 ), using SHA-1 as the hash function for + * EMSA-PKCS1-v1_5. It is assumed that the Consumer has provided its RSA public key in a + * verified way to the Service Provider, in a manner which is beyond the scope of this + * specification. + * - Chapter 9.3 ( "RSA-SHA1" ) + */ +abstract class OAuthSignatureMethod_RSA_SHA1 extends OAuthSignatureMethod { + public function get_name() { + return "RSA-SHA1"; + } + + // Up to the SP to implement this lookup of keys. Possible ideas are: + // ( 1 ) do a lookup in a table of trusted certs keyed off of consumer + // ( 2 ) fetch via http using a url provided by the requester + // ( 3 ) some sort of specific discovery code based on request + // + // Either way should return a string representation of the certificate + protected abstract function fetch_public_cert( &$request ); + + // Up to the SP to implement this lookup of keys. Possible ideas are: + // ( 1 ) do a lookup in a table of trusted certs keyed off of consumer + // + // Either way should return a string representation of the certificate + protected abstract function fetch_private_cert( &$request ); + + public function build_signature( $request, $consumer, $token ) { + $base_string = $request->get_signature_base_string(); + $request->base_string = $base_string; + + // Fetch the private key cert based on the request + $cert = $this->fetch_private_cert( $request ); + + // Pull the private key ID from the certificate + $privatekeyid = openssl_get_privatekey( $cert ); + + // Sign using the key + $ok = openssl_sign( $base_string, $signature, $privatekeyid ); + + // Release the key resource + openssl_free_key( $privatekeyid ); + + return base64_encode( $signature ); + } + + public function check_signature( $request, $consumer, $token, $signature ) { + $decoded_sig = base64_decode( $signature ); + + $base_string = $request->get_signature_base_string(); + + // Fetch the public key cert based on the request + $cert = $this->fetch_public_cert( $request ); + + // Pull the public key ID from the certificate + $publickeyid = openssl_get_publickey( $cert ); + + // Check the computed signature against the one passed in the query + $ok = openssl_verify( $base_string, $decoded_sig, $publickeyid ); + + // Release the key resource + openssl_free_key( $publickeyid ); + + return $ok == 1; + } +} + +class OAuthRequest { + protected $parameters; + protected $http_method; + protected $http_url; + // for debug purposes + public $base_string; + public static $version = '1.0'; + public static $POST_INPUT = 'php://input'; + + function __construct( $http_method, $http_url, $parameters = NULL ) { + $parameters = ($parameters) ? $parameters : array(); + $parameters = array_merge( OAuthUtil::parse_parameters(parse_url($http_url, PHP_URL_QUERY)), $parameters); + $this->parameters = $parameters; + $this->http_method = $http_method; + $this->http_url = $http_url; + } + + + /** + * attempt to build up a request from what was passed to the server + */ + public static function from_request( $http_method = NULL, $http_url = NULL, $parameters = NULL ) { + $scheme = ( !isset( $_SERVER['HTTPS'] ) || $_SERVER['HTTPS'] != "on" ) + ? 'http' + : 'https'; + $http_url = ( $http_url ) ? $http_url : $scheme . + '://' . $_SERVER['SERVER_NAME'] . + ':' . + $_SERVER['SERVER_PORT'] . + $_SERVER['REQUEST_URI']; + $http_method = ( $http_method ) ? $http_method : $_SERVER['REQUEST_METHOD']; + + // We weren't handed any parameters, so let's find the ones relevant to + // this request. + // If you run XML-RPC or similar you should use this to provide your own + // parsed parameter-list + if ( !$parameters ) { + // Find request headers + $request_headers = OAuthUtil::get_headers(); + + // Parse the query-string to find GET parameters + $parameters = OAuthUtil::parse_parameters( $_SERVER['QUERY_STRING'] ); + + // It's a POST request of the proper content-type, so parse POST + // parameters and add those overriding any duplicates from GET + if ( $http_method == "POST" + && isset( $request_headers['Content-Type'] ) + && strstr( $request_headers['Content-Type'], + 'application/x-www-form-urlencoded' + ) + ) { + $post_data = OAuthUtil::parse_parameters( file_get_contents( self::$POST_INPUT ) ); + $parameters = array_merge( $parameters, $post_data ); + } + + // We have a Authorization-header with OAuth data. Parse the header + // and add those overriding any duplicates from GET or POST + if ( isset( $request_headers['Authorization'] ) + && substr( $request_headers['Authorization'], 0, 6 ) == 'OAuth ' + ) { + $header_parameters = OAuthUtil::split_header( $request_headers['Authorization'] ); + $parameters = array_merge( $parameters, $header_parameters ); + } + + } + + return new OAuthRequest( $http_method, $http_url, $parameters ); + } + + /** + * pretty much a helper function to set up the request + */ + public static function from_consumer_and_token( $consumer, $token, $http_method, $http_url, $parameters = NULL ) { + $parameters = ( $parameters ) ? $parameters : array(); + $defaults = array( "oauth_version" => OAuthRequest::$version, + "oauth_nonce" => OAuthRequest::generate_nonce(), + "oauth_timestamp" => OAuthRequest::generate_timestamp(), + "oauth_consumer_key" => $consumer->key ); + if ( $token ) { + $defaults['oauth_token'] = $token->key; + } + + $parameters = array_merge( $defaults, $parameters ); + + return new OAuthRequest( $http_method, $http_url, $parameters ); + } + + public function set_parameter( $name, $value, $allow_duplicates = true ) { + if ( $allow_duplicates && isset( $this->parameters[$name] ) ) { + // We have already added parameter( s ) with this name, so add to the list + if ( is_scalar( $this->parameters[$name] ) ) { + // This is the first duplicate, so transform scalar ( string ) + // into an array so we can add the duplicates + $this->parameters[$name] = array( $this->parameters[$name] ); + } + + $this->parameters[$name][] = $value; + } else { + $this->parameters[$name] = $value; + } + } + + public function get_parameter( $name ) { + return isset( $this->parameters[$name] ) ? $this->parameters[$name] : null; + } + + public function get_parameters() { + return $this->parameters; + } + + public function unset_parameter( $name ) { + unset( $this->parameters[$name] ); + } + + /** + * The request parameters, sorted and concatenated into a normalized string. + * @return string + */ + public function get_signable_parameters() { + // Grab all parameters + $params = $this->parameters; + + // Remove oauth_signature if present + // Ref: Spec: 9.1.1 ( "The oauth_signature parameter MUST be excluded." ) + if ( isset( $params['oauth_signature'] ) ) { + unset( $params['oauth_signature'] ); + } + + return OAuthUtil::build_http_query( $params ); + } + + /** + * Returns the base string of this request + * + * The base string defined as the method, the url + * and the parameters ( normalized ), each urlencoded + * and the concated with &. + */ + public function get_signature_base_string() { + //wfDebugLog( 'OAuth', __METHOD__ . ": Generating base string when this->paramters:\n" . print_r( $this->parameters, true ) ); + $parts = array( + $this->get_normalized_http_method(), + $this->get_normalized_http_url(), + $this->get_signable_parameters() + ); + + $parts = OAuthUtil::urlencode_rfc3986( $parts ); + + return implode( '&', $parts ); + } + + /** + * just uppercases the http method + */ + public function get_normalized_http_method() { + return strtoupper( $this->http_method ); + } + + /** + * parses the url and rebuilds it to be + * scheme://host/path + */ + public function get_normalized_http_url() { + $parts = parse_url( $this->http_url ); + + $scheme = ( isset( $parts['scheme'] ) ) ? $parts['scheme'] : 'http'; + $port = ( isset( $parts['port'] ) ) ? $parts['port'] : ( ( $scheme == 'https' ) ? '443' : '80' ); + $host = ( isset( $parts['host'] ) ) ? strtolower( $parts['host'] ) : ''; + $path = ( isset( $parts['path'] ) ) ? $parts['path'] : ''; + + if ( ( $scheme == 'https' && $port != '443' ) + || ( $scheme == 'http' && $port != '80' ) ) { + $host = "$host:$port"; + } + return "$scheme://$host$path"; + } + + /** + * builds a url usable for a GET request + */ + public function to_url() { + $post_data = $this->to_postdata(); + $out = $this->get_normalized_http_url(); + if ( $post_data ) { + $out .= '?'.$post_data; + } + return $out; + } + + /** + * builds the data one would send in a POST request + */ + public function to_postdata() { + return OAuthUtil::build_http_query( $this->parameters ); + } + + /** + * builds the Authorization: header + */ + public function to_header( $realm = null ) { + $first = true; + if( $realm ) { + $out = 'Authorization: OAuth realm="' . OAuthUtil::urlencode_rfc3986( $realm ) . '"'; + $first = false; + } else { + $out = 'Authorization: OAuth'; + } + $total = array(); + foreach ( $this->parameters as $k => $v ) { + if ( substr( $k, 0, 5 ) != "oauth" ) continue; + if ( is_array( $v ) ) { + throw new OAuthException( 'Arrays not supported in headers' ); + } + $out .= ( $first ) ? ' ' : ','; + $out .= OAuthUtil::urlencode_rfc3986( $k ) . + '="' . + OAuthUtil::urlencode_rfc3986( $v ) . + '"'; + $first = false; + } + return $out; + } + + public function __toString() { + return $this->to_url(); + } + + + public function sign_request( $signature_method, $consumer, $token ) { + $this->set_parameter( + "oauth_signature_method", + $signature_method->get_name(), + false + ); + $signature = $this->build_signature( $signature_method, $consumer, $token ); + $this->set_parameter( "oauth_signature", $signature, false ); + } + + public function build_signature( $signature_method, $consumer, $token ) { + $signature = $signature_method->build_signature( $this, $consumer, $token ); + return $signature; + } + + /** + * util function: current timestamp + */ + private static function generate_timestamp() { + return time(); + } + + /** + * util function: current nonce + */ + private static function generate_nonce() { + $mt = microtime(); + $rand = mt_rand(); + + return md5( $mt . $rand ); // md5s look nicer than numbers + } +} + +class OAuthServer { + protected $timestamp_threshold = 300; // in seconds, five minutes + protected $version = '1.0'; // hi blaine + protected $signature_methods = array(); + + protected $data_store; + + function __construct( $data_store ) { + $this->data_store = $data_store; + } + + public function add_signature_method( $signature_method ) { + $this->signature_methods[$signature_method->get_name()] = + $signature_method; + } + + // high level functions + + /** + * process a request_token request + * returns the request token on success + */ + public function fetch_request_token( &$request ) { + $this->get_version( $request ); + + $consumer = $this->get_consumer( $request ); + + // no token required for the initial token request + $token = NULL; + + $this->check_signature( $request, $consumer, $token ); + + // Rev A change + $callback = $request->get_parameter( 'oauth_callback' ); + $new_token = $this->data_store->new_request_token( $consumer, $callback ); + + return $new_token; + } + + /** + * process an access_token request + * returns the access token on success + */ + public function fetch_access_token( &$request ) { + $this->get_version( $request ); + + $consumer = $this->get_consumer( $request ); + + // requires authorized request token + $token = $this->get_token( $request, $consumer, "request" ); + + $this->check_signature( $request, $consumer, $token ); + + // Rev A change + $verifier = $request->get_parameter( 'oauth_verifier' ); + $new_token = $this->data_store->new_access_token( $token, $consumer, $verifier ); + + return $new_token; + } + + /** + * verify an api call, checks all the parameters + */ + public function verify_request( &$request ) { + $this->get_version( $request ); + $consumer = $this->get_consumer( $request ); + $token = $this->get_token( $request, $consumer, "access" ); + $this->check_signature( $request, $consumer, $token ); + return array( $consumer, $token ); + } + + // Internals from here + /** + * version 1 + */ + protected function get_version( &$request ) { + $version = $request->get_parameter( "oauth_version" ); + if ( !$version ) { + // Service Providers MUST assume the protocol version to be 1.0 if this parameter is not present. + // Chapter 7.0 ( "Accessing Protected Ressources" ) + $version = '1.0'; + } + if ( $version !== $this->version ) { + throw new OAuthException( "OAuth version '$version' not supported" ); + } + return $version; + } + + /** + * figure out the signature with some defaults + */ + private function get_signature_method( $request ) { + $signature_method = $request instanceof OAuthRequest + ? $request->get_parameter( "oauth_signature_method" ) + : NULL; + + if ( !$signature_method ) { + // According to chapter 7 ( "Accessing Protected Ressources" ) the signature-method + // parameter is required, and we can't just fallback to PLAINTEXT + throw new OAuthException( 'No signature method parameter. This parameter is required' ); + } + + if ( !in_array( $signature_method, array_keys( $this->signature_methods ) ) ) { + throw new OAuthException( + "Signature method '$signature_method' not supported " . + "try one of the following: " . + implode( ", ", array_keys( $this->signature_methods ) ) + ); + } + return $this->signature_methods[$signature_method]; + } + + /** + * try to find the consumer for the provided request's consumer key + */ + protected function get_consumer( $request ) { + $consumer_key = $request instanceof OAuthRequest + ? $request->get_parameter( "oauth_consumer_key" ) + : NULL; + + if ( !$consumer_key ) { + throw new OAuthException( "Invalid consumer key" ); + } + wfDebugLog( 'OAuth', __METHOD__ . ": getting consumer for '$consumer_key'" ); + $consumer = $this->data_store->lookup_consumer( $consumer_key ); + if ( !$consumer ) { + throw new OAuthException( "Invalid consumer" ); + } + + return $consumer; + } + + /** + * try to find the token for the provided request's token key + */ + protected function get_token( $request, $consumer, $token_type = "access" ) { + $token_field = $request instanceof OAuthRequest + ? $request->get_parameter( 'oauth_token' ) + : NULL; + + $token = $this->data_store->lookup_token( + $consumer, $token_type, $token_field + ); + if ( !$token ) { + throw new OAuthException( "Invalid $token_type token: $token_field" ); + } + return $token; + } + + /** + * all-in-one function to check the signature on a request + * should guess the signature method appropriately + */ + protected function check_signature( $request, $consumer, $token ) { + // this should probably be in a different method + $timestamp = $request instanceof OAuthRequest + ? $request->get_parameter( 'oauth_timestamp' ) + : NULL; + $nonce = $request instanceof OAuthRequest + ? $request->get_parameter( 'oauth_nonce' ) + : NULL; + + $this->check_timestamp( $timestamp ); + $this->check_nonce( $consumer, $token, $nonce, $timestamp ); + + $signature_method = $this->get_signature_method( $request ); + $signature = $request->get_parameter( 'oauth_signature' ); + $valid_sig = $signature_method->check_signature( + $request, + $consumer, + $token, + $signature + ); + + if ( !$valid_sig ) { + wfDebugLog( 'OAuth', __METHOD__ . ': Signature check (' . get_class( $signature_method ) . ') failed' ); + throw new OAuthException( "Invalid signature" ); + } + } + + /** + * check that the timestamp is new enough + */ + private function check_timestamp( $timestamp ) { + if( !$timestamp ) { + throw new OAuthException( + 'Missing timestamp parameter. The parameter is required' + ); + } + + // verify that timestamp is recentish + $now = time(); + if ( abs( $now - $timestamp ) > $this->timestamp_threshold ) { + throw new OAuthException( + "Expired timestamp, yours $timestamp, ours $now" + ); + } + } + + /** + * check that the nonce is not repeated + */ + private function check_nonce( $consumer, $token, $nonce, $timestamp ) { + if( !$nonce ) { + throw new OAuthException( + 'Missing nonce parameter. The parameter is required' + ); + } + + // verify that the nonce is uniqueish + $found = $this->data_store->lookup_nonce( + $consumer, + $token, + $nonce, + $timestamp + ); + if ( $found ) { + throw new OAuthException( "Nonce already used: $nonce" ); + } + } + +} + +class OAuthDataStore { + function lookup_consumer( $consumer_key ) { + // implement me + } + + function lookup_token( $consumer, $token_type, $token ) { + // implement me + } + + function lookup_nonce( $consumer, $token, $nonce, $timestamp ) { + // implement me + } + + function new_request_token( $consumer, $callback = null ) { + // return a new token attached to this consumer + } + + function new_access_token( $token, $consumer, $verifier = null ) { + // return a new access token attached to this consumer + // for the user associated with this token if the request token + // is authorized + // should also invalidate the request token + } + +} + +class OAuthUtil { + public static function urlencode_rfc3986( $input ) { + if ( is_array( $input ) ) { + return array_map( array( 'OAuthUtil', 'urlencode_rfc3986' ), $input ); + } else if ( is_scalar( $input ) ) { + return str_replace( + '+', + ' ', + str_replace( '%7E', '~', rawurlencode( $input ) ) + ); + } else { + return ''; + } + } + + + // This decode function isn't taking into consideration the above + // modifications to the encoding process. However, this method doesn't + // seem to be used anywhere so leaving it as is. + public static function urldecode_rfc3986( $string ) { + return urldecode( $string ); + } + + // Utility function for turning the Authorization: header into + // parameters, has to do some unescaping + // Can filter out any non-oauth parameters if needed ( default behaviour ) + // May 28th, 2010 - method updated to tjerk.meesters for a speed improvement. + // see http://code.google.com/p/oauth/issues/detail?id = 163 + public static function split_header( $header, $only_allow_oauth_parameters = true ) { + wfDebugLog( 'OAuth', __METHOD__ . ": pulling headers from '$header'" ); + $params = array(); + if ( preg_match_all( '/(' . ( $only_allow_oauth_parameters ? 'oauth_' : '' ) . '[a-z_-]*)=(:?"([^"]*)"|([^,]*))/', $header, $matches ) ) { + foreach ( $matches[1] as $i => $h ) { + wfDebugLog( 'OAuth', __METHOD__ . ": '$i' => '$h'" ); + $params[$h] = OAuthUtil::urldecode_rfc3986( empty( $matches[3][$i] ) ? $matches[4][$i] : $matches[3][$i] ); + } + if ( isset( $params['realm'] ) ) { + unset( $params['realm'] ); + } + } + return $params; + } + + // helper to try to sort out headers for people who aren't running apache + public static function get_headers() { + if ( function_exists( 'apache_request_headers' ) ) { + // we need this to get the actual Authorization: header + // because apache tends to tell us it doesn't exist + $headers = apache_request_headers(); + + // sanitize the output of apache_request_headers because + // we always want the keys to be Cased-Like-This and arh() + // returns the headers in the same case as they are in the + // request + $out = array(); + foreach ( $headers AS $key => $value ) { + $key = str_replace( + " ", + "-", + ucwords( strtolower( str_replace( "-", " ", $key ) ) ) + ); + $out[$key] = $value; + } + } else { + // otherwise we don't have apache and are just going to have to hope + // that $_SERVER actually contains what we need + $out = array(); + if( isset( $_SERVER['CONTENT_TYPE'] ) ) + $out['Content-Type'] = $_SERVER['CONTENT_TYPE']; + if( isset( $_ENV['CONTENT_TYPE'] ) ) + $out['Content-Type'] = $_ENV['CONTENT_TYPE']; + + foreach ( $_SERVER as $key => $value ) { + if ( substr( $key, 0, 5 ) == "HTTP_" ) { + // this is chaos, basically it is just there to capitalize the first + // letter of every word that is not an initial HTTP and strip HTTP + // code from przemek + $key = str_replace( + " ", + "-", + ucwords( strtolower( str_replace( "_", " ", substr( $key, 5 ) ) ) ) + ); + $out[$key] = $value; + } + } + } + return $out; + } + + // This function takes a input like a=b&a=c&d=e and returns the parsed + // parameters like this + // array( 'a' => array( 'b','c' ), 'd' => 'e' ) + public static function parse_parameters( $input ) { + if ( !isset( $input ) || !$input ) return array(); + + $pairs = explode( '&', $input ); + + $parsed_parameters = array(); + foreach ( $pairs as $pair ) { + $split = explode( '=', $pair, 2 ); + $parameter = OAuthUtil::urldecode_rfc3986( $split[0] ); + $value = isset( $split[1] ) ? OAuthUtil::urldecode_rfc3986( $split[1] ) : ''; + + if ( isset( $parsed_parameters[$parameter] ) ) { + // We have already recieved parameter( s ) with this name, so add to the list + // of parameters with this name + + if ( is_scalar( $parsed_parameters[$parameter] ) ) { + // This is the first duplicate, so transform scalar ( string ) into an array + // so we can add the duplicates + $parsed_parameters[$parameter] = array( $parsed_parameters[$parameter] ); + } + + $parsed_parameters[$parameter][] = $value; + } else { + $parsed_parameters[$parameter] = $value; + } + } + return $parsed_parameters; + } + + public static function build_http_query( $params ) { + wfDebugLog( 'OAuth', __METHOD__ . " called with params:\n" . print_r( $params, true ) ); + if ( !$params ) return ''; + + // Urlencode both keys and values + $keys = OAuthUtil::urlencode_rfc3986( array_keys( $params ) ); + $values = OAuthUtil::urlencode_rfc3986( array_values( $params ) ); + $params = array_combine( $keys, $values ); + + // Parameters are sorted by name, using lexicographical byte value ordering. + // Ref: Spec: 9.1.1 ( 1 ) + uksort( $params, 'strcmp' ); + + $pairs = array(); + foreach ( $params as $parameter => $value ) { + if ( is_array( $value ) ) { + // If two or more parameters share the same name, they are sorted by their value + // Ref: Spec: 9.1.1 ( 1 ) + // June 12th, 2010 - changed to sort because of issue 164 by hidetaka + sort( $value, SORT_STRING ); + foreach ( $value as $duplicate_value ) { + $pairs[] = $parameter . '=' . $duplicate_value; + } + } else { + $pairs[] = $parameter . '=' . $value; + } + } + // For each parameter, the name is separated from the corresponding value by an ' = ' character ( ASCII code 61 ) + // Each name-value pair is separated by an '&' character ( ASCII code 38 ) + return implode( '&', $pairs ); + } +} + +?> diff --git a/specials/SpecialOAuthLogin.php b/specials/SpecialOAuthLogin.php new file mode 100644 index 0000000..c427e7a --- /dev/null +++ b/specials/SpecialOAuthLogin.php @@ -0,0 +1,106 @@ +<?php + +namespace MediaWiki\Extensions\OAuthAuthentication; + +class SpecialOAuthLogin extends \UnlistedSpecialPage { + + function __construct() { + parent::__construct( 'OAuthLogin' ); + } + + + public function execute( $subpage ) { + global $wgUser; + $request = $this->getRequest(); + + $this->setHeaders(); + + if ( !$this->getUser()->isAnon() ) { + throw new \ErrorPageError( 'oauthauth-error', 'oauthauth-already-logged-in' ); + } + + $handler = false; + $session = new PhpSessionStore( $request ); + + list( $config, $cmrToken ) = Config::getDefaultConfigAndToken(); + $client = new \MWOAuthClient( $config, $cmrToken ); + $handler = new OAuth1Handler(); + + switch ( trim( $subpage ) ) { + case 'init': + + // Keep around returnto/returntoquery and set with PostLoginRedirect hook + $session->set( + 'oauth-init-returnto', + $request->getVal( 'returnto', 'Main_Page' ) + ); + $session->set( + 'oauth-init-returntoquery', + $request->getVal( 'returntoquery' ) + ); + + try { + $redir = $handler->init( + $session, + $client + ); + + $handler->authorize( $this->getRequest()->response(), $redir ); + + } catch ( Exception $e ) { + throw new \ErrorPageError( 'oauthauth-error', $e->getMessage() ); + } + if ( !$status->isGood() ) { + throw new \ErrorPageError( 'oauthauth-error', $status->getMessage() ); + } + + break; + case 'finish': + try { + $accessToken = $handler->finish( + $this->getRequest(), + $session, + $client + ); + + $identity = $handler->identify( + $accessToken, + $client + ); + + if ( !Policy::checkWhitelists( $identity ) ) { + throw new \ErrorPageError( 'oauthauth-error', 'oauthauth-nologin-policy' ); + } + + $status = AuthenticationHandler::handleIdentity( + $this->getRequest(), + $identity, + $accessToken + ); + + if ( !$status->isGood() ) { + throw new \ErrorPageError( 'oauthauth-error', $status->getMessage() ); + } + } catch ( \ErrorPageError $epe ) { + throw $epe; + } catch ( Exception $e ) { + throw new \ErrorPageError( 'oauthauth-error', $e->getMessage() ); + } + + list( $method, $u ) = $status->getValue(); + + $this->getContext()->setUser( $u ); + $wgUser = $u; + + $lp = new \LoginForm(); + + // Call LoginForm::successfulCreation() on create, or successfulLogin() + $lp->$method(); + break; + default: + throw new \ErrorPageError( 'oauthauth-error', 'oauthauth-invalid-subpage' ); + } + + } + +} diff --git a/store/PhpSessionStore.php b/store/PhpSessionStore.php new file mode 100644 index 0000000..7d2399f --- /dev/null +++ b/store/PhpSessionStore.php @@ -0,0 +1,25 @@ +<?php + +namespace MediaWiki\Extensions\OAuthAuthentication; + +class PhpSessionStore extends SessionStore { + + private $request; + + public function __construct( \WebRequest $request ) { + wfSetupSession(); + $this->request = $request; + } + + public function get( $key ) { + return $this->request->getSessionData( $key ); + } + + public function set( $key, $value ) { + $this->request->setSessionData( $key, $value ); + } + + public function delete( $key ) { + $this->request->setSessionData( $key, null ); + } +} diff --git a/store/SessionStore.php b/store/SessionStore.php new file mode 100644 index 0000000..72f1a80 --- /dev/null +++ b/store/SessionStore.php @@ -0,0 +1,12 @@ +<?php + +namespace MediaWiki\Extensions\OAuthAuthentication; + +abstract class SessionStore { + + abstract public function get( $key ); + + abstract public function set( $key, $value ); + + abstract public function delete( $key ); +} diff --git a/store/oauthauth.sql b/store/oauthauth.sql new file mode 100644 index 0000000..56fc6f5 --- /dev/null +++ b/store/oauthauth.sql @@ -0,0 +1,11 @@ +CREATE TABLE /*_*/oauthauth_user ( + `oaau_rid` int(10) unsigned NOT NULL, + `oaau_uid` int(10) unsigned NOT NULL PRIMARY KEY, + `oaau_username` varchar(255) binary not null, + `oaau_access_token` varchar(127) binary not null default '', + `oaau_access_secret` varchar(127) binary not null default '', + `oaau_identify_timestamp` binary(14) not null default '', +) /*$wgDBTableOptions*/; + +CREATE UNIQUE INDEX /*i*/idx_rid ON /*_*/oauthauth_user (`oaau_rid`); + diff --git a/tests/OAuthAuthConfigTest.php b/tests/OAuthAuthConfigTest.php new file mode 100644 index 0000000..07a87b2 --- /dev/null +++ b/tests/OAuthAuthConfigTest.php @@ -0,0 +1,13 @@ +<?php +/** + * @group OAuthAuthentication + */ +class OAuthAuthConfigTest extends OAuthAuthDBTest { + + public function testGetDefaultConfigAndToken() { + list( $config, $token ) = \MediaWiki\Extensions\OAuthAuthentication\Config::getDefaultConfigAndToken(); + $this->assertInstanceOf( 'MWOAuthClientConfig', $config ); + $this->assertInstanceOf( 'OAuthToken', $token ); + } + +} diff --git a/tests/OAuthAuthDBTest.php b/tests/OAuthAuthDBTest.php new file mode 100644 index 0000000..8080ceb --- /dev/null +++ b/tests/OAuthAuthDBTest.php @@ -0,0 +1,43 @@ +<?php +/** + * @group OAuthAuthentication + */ +class OAuthAuthDBTest extends MediaWikiTestCase { + + public function __construct( $name = null, array $data = array(), $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + } + + protected function setUp() { + parent::setUp(); + if ( $this->db->tableExists( 'oauthauth_user' ) ) { + $this->db->dropTable( 'oauthauth_user' ); + } + $this->db->sourceFile( __DIR__ . '/../store/oauthauth.sql' ); + + // TODO: Setup some test data + $user = User::newFromName( 'OAuthUser' ); + if ( $user->idForName() == 0 ) { + $user->addToDatabase(); + $user->setPassword( 'OAUP@ssword' ); + $user->saveSettings(); + } + $exUser = new \MediaWiki\Extensions\OAuthAuthentication\OAuthExternalUser( 100, $user->getId(), 'OAuthUser' ); + $exUser->addToDatabase( $this->db ); + } + + protected function tearDown() { + $this->db->dropTable( 'oauthauth_user' ); + parent::tearDown(); + } + + public function needsDB() { + return true; + } + + // Stub to make sure db handling is working + public function testInit() { + $this->assertSame( true, true ); + } + +} diff --git a/tests/OAuthAuthExternalUserTest.php b/tests/OAuthAuthExternalUserTest.php new file mode 100644 index 0000000..d739b28 --- /dev/null +++ b/tests/OAuthAuthExternalUserTest.php @@ -0,0 +1,20 @@ +<?php +/** + * @group OAuthAuthentication + */ +class OAuthAuthExternalUserTest extends OAuthAuthDBTest { + + public function testExternalUser() { + $exUser = new \MediaWiki\Extensions\OAuthAuthentication\OAuthExternalUser( 20, 30, 'ExUser' ); + $this->assertEquals( 'ExUser', $exUser->getName() ); + $this->assertEquals( 30, $exUser->getLocalId() ); + } + + public function testNewFromRemoteId() { + // We added remoteId 120 in parent class + $exUser = \MediaWiki\Extensions\OAuthAuthentication\OAuthExternalUser::newFromRemoteId( 120, 'OAuthUser', $this->db ); + $this->assertInstanceOf( 'MediaWiki\Extensions\OAuthAuthentication\OAuthExternalUser', $exUser ); + $this->assertEquals( 'OAuthUser', $exUser->getName() ); + } + +} diff --git a/tests/OAuthAuthHooksTest.php b/tests/OAuthAuthHooksTest.php new file mode 100644 index 0000000..b3b8735 --- /dev/null +++ b/tests/OAuthAuthHooksTest.php @@ -0,0 +1,24 @@ +<?php +/** + * @group OAuthAuthentication + */ +class OAuthAuthHooksTest extends OAuthAuthDBTest { + + public function testOnPersonalUrls() { + + $this->setMwGlobals( array( + 'wgUser' => \User::newFromName( '127.0.0.1', false ), + ) ); + + $personal_urls = array( 'login' => array( 'href' => 'fail' ) ); + $title = new Title(); + + \MediaWiki\Extensions\OAuthAuthentication\Hooks::onPersonalUrls( $personal_urls, $title ); + + $this->assertSame( + true, + strpos( $personal_urls['login']['href'], 'Special:OAuthLogin/init' ) !== false + ); + } + +} diff --git a/utils/Config.php b/utils/Config.php new file mode 100644 index 0000000..bb415ae --- /dev/null +++ b/utils/Config.php @@ -0,0 +1,40 @@ +<?php + +namespace MediaWiki\Extensions\OAuthAuthentication; + +class Config { + + public static function getDefaultConfigAndToken() { + global $wgOAuthAuthenticationConsumerKey, + $wgOAuthAuthenticationConsumerSecret, + $wgOAuthAuthenticationUrl, + $wgOAuthAuthenticationCanonicalUrl, + $wgOAuthAuthenticationValidateSSL; + + $validateSSL = false; + $useSSL = false; + + if ( preg_match( '!^https://!i', $wgOAuthAuthenticationUrl ) ) { + $validateSSL = $wgOAuthAuthenticationValidateSSL; + $useSSL = true; + } + + $config = new \MWOAuthClientConfig( + $wgOAuthAuthenticationUrl, // url to use + $useSSL, // do we use SSL? (we should probably detect that from the url) + $validateSSL // do we validate the SSL certificate? Always use 'true' in production. + ); + + if ( $wgOAuthAuthenticationCanonicalUrl ) { + $config->canonicalServerUrl = $wgOAuthAuthenticationCanonicalUrl; + } + // Optional clean url here (i.e., to work with mobile), otherwise the + // base url just has /authorize& added + #$config->redirURL = 'http://en.wikipedia.beta.wmflabs.org/wiki/Special:OAuth/authorize?'; + + $cmrToken = new \OAuthToken( $wgOAuthAuthenticationConsumerKey, $wgOAuthAuthenticationConsumerSecret ); + + return array( $config, $cmrToken ); + } + +} diff --git a/utils/Exception.php b/utils/Exception.php new file mode 100644 index 0000000..0ccfeae --- /dev/null +++ b/utils/Exception.php @@ -0,0 +1,7 @@ +<?php + +namespace MediaWiki\Extensions\OAuthAuthentication; + +class Exception extends \MWException { + +} diff --git a/utils/Hooks.php b/utils/Hooks.php new file mode 100644 index 0000000..6df4a81 --- /dev/null +++ b/utils/Hooks.php @@ -0,0 +1,156 @@ +<?php +namespace MediaWiki\Extensions\OAuthAuthentication; + +class Hooks { + + public static function onPersonalUrls( &$personal_urls, &$title ) { + global $wgUser, $wgRequest, + $wgOAuthAuthenticationAllowLocalUsers, $wgOAuthAuthenticationRemoteName; + + if ( $wgUser->getID() == 0 ) { + $query = array(); + $query['returnto'] = $title->getPrefixedText(); + $returntoquery = $wgRequest->getValues(); + unset( $returntoquery['title'] ); + unset( $returntoquery['returnto'] ); + unset( $returntoquery['returntoquery'] ); + $query['returntoquery'] = wfArrayToCgi( $returntoquery ); + $personal_urls['login']['href'] = \SpecialPage::getTitleFor( 'OAuthLogin', 'init' )->getFullURL( $query ); + if ( $wgOAuthAuthenticationRemoteName ) { + $personal_urls['login']['text'] = wfMessage( 'oauthauth-login', + $wgOAuthAuthenticationRemoteName )->text(); + } + + if ( $wgOAuthAuthenticationAllowLocalUsers === false ) { + unset( $personal_urls['createaccount'] ); + } + } + return true; + } + + public static function onPostLoginRedirect( &$returnTo, &$returnToQuery, &$type ) { + global $wgRequest; + $session = new PhpSessionStore( $wgRequest ); + + $title = $session->get( 'oauth-init-returnto' ); + $query = $session->get( 'oauth-init-returntoquery' ); + + if ( $title ) { + $returnTo = $title; + } + + if ( $query ) { + $returnToQuery = $query; + } + } + + public static function onLoadExtensionSchemaUpdates( $updater = null ) { + $updater->addExtensionTable( 'oauthauth_user', __DIR__ . '../store/oauthauth.sql' ); + } + + public static function onGetPreferences( $user, &$preferences ) { + global $wgRequirePasswordforEmailChange; + + $resetlink = \Linker::link( + \SpecialPage::getTitleFor( 'PasswordReset' ), + wfMessage( 'passwordreset' )->escaped(), + array(), + array( 'returnto' => \SpecialPage::getTitleFor( 'Preferences' ) ) + ); + + if ( empty( $user->mPassword ) && empty( $user->mNewpassword ) ) { + + if ( $user->isEmailConfirmed() ) { + $preferences['password'] = array( + 'section' => 'personal/info', + 'type' => 'info', + 'raw' => true, + 'default' => $resetlink, + 'label-message' => 'yourpassword', + ); + } else { + unset( $preferences['password'] ); + } + + if ( $wgRequirePasswordforEmailChange ) { + $preferences['emailaddress'] = array( + 'type' => 'info', + 'raw' => 1, + 'default' => wfMessage( 'oauthauth-set-email' )->escaped(), + 'section' => 'personal/email', + 'label-message' => 'youremail', + 'cssclass' => 'mw-email-none', + ); + } + + } else { + $preferences['resetpassword'] = array( + 'section' => 'personal/info', + 'type' => 'info', + 'raw' => true, + 'default' => $resetlink, + 'label-message' => null, + ); + } + } + + /** + * Check that the identity complies with the site policy + * + */ + public static function onUserLoadAfterLoadFromSession( $user ) { + global $wgOAuthAuthenticationMaxIdentityAge; + + if ( Policy::policyToEnforce() ) { + if ( !isset( $user->extAuthObj ) ) { + $user->extAuthObj = OAuthExternalUser::newFromUser( $user, wfGetDB( DB_MASTER ) ); + } + + if ( $user->extAuthObj ) { + $lastVerify = new \MWTimestamp( $user->extAuthObj->getIdentifyTS() ); + $minVerify = new \MWTimestamp( time() - $wgOAuthAuthenticationMaxIdentityAge ); + + if ( $lastVerify->getTimestamp() <= $minVerify->getTimestamp() ) { + list( $config, $cmrToken ) = Config::getDefaultConfigAndToken(); + $client = new \MWOAuthClient( $config, $cmrToken ); + $handler = new OAuth1Handler(); + $identity = $handler->identify( $user->extAuthObj->getAccessToken(), $client ); + $user->extAuthObj->setIdentifyTS( new \MWTimestamp() ); + $user->extAuthObj->updateInDatabase( wfGetDB( DB_MASTER ) ); + if ( !Policy::checkWhitelists( $identity ) ) { + $user->logout(); + throw new \ErrorPageError( 'oauthauth-error', 'oauthauth-loggout-policy' ); + } + } + } + } + + return true; + } + + /** + * @param $user User + * @param $abortError + * @return bool + */ + static function onAbortNewAccount( $user, &$abortError ) { + global $wgOAuthAuthenticationAllowLocalUsers, $wgRequest; + + if ( $wgOAuthAuthenticationAllowLocalUsers === false ) { + $query = array(); + $query['returnto'] = $wgRequest->getVal( 'returnto' ); + $query['returntoquery'] = $wgRequest->getVal( 'returntoquery' ); + $loginTitle = \SpecialPage::getTitleFor( 'OAuthLogin', 'init' ); + $loginlink = \Linker::Link( + $loginTitle, + wfMessage( 'login' )->escaped(), + array(), + $query + ); + $msg = wfMessage( 'oauthauth-localuser-not-allowed' )->rawParams( $loginlink ); + $abortError = $msg->escaped(); + return false; + } + } + +} diff --git a/utils/OAuthExternalUser.php b/utils/OAuthExternalUser.php new file mode 100644 index 0000000..2b9d21e --- /dev/null +++ b/utils/OAuthExternalUser.php @@ -0,0 +1,174 @@ +<?php + +namespace MediaWiki\Extensions\OAuthAuthentication; + +class OAuthExternalUser { + + // Local user_id + private $userId; + + // Remote Username + private $username; + + // Remote unique id + private $remoteId; + + // OAuth Access Token + private $accessToken = null; + + // Timestamp of last identity validation + private $identifyTS = null; + + public function __construct( $rid, $uid, $name, $accessKey = '', $accessSecret = '', $idts = null ) { + $this->remoteId = $rid; + $this->userId = $uid; // OIDC specifies this is unique for the IdP + $this->username = $name; + + if ( $accessKey && $accessSecret ) { + $this->accessToken = new \OAuthToken( $accessKey, $accessSecret ); + } + + $this->identifyTS = $idts; + } + + public static function newFromRemoteId( $rid, $username, \DatabaseBase $db ) { + $row = $db->selectRow( + 'oauthauth_user', + array( 'oaau_rid', 'oaau_uid', 'oaau_username', 'oaau_access_token', + 'oaau_access_secret', 'oaau_identify_timestamp' ), + array( 'oaau_rid' => $rid ), + __METHOD__ + ); + + if ( !$row ) { + return new self( $rid, 0, $username ); + } else { + return new self( $rid, $row->oaau_uid, $row->oaau_username, + $row->oaau_access_token, $row->oaau_access_secret, + $row->oaau_identify_timestamp ); + } + } + + public static function newFromUser( \User $user, \DatabaseBase $db ) { + $row = $db->selectRow( + 'oauthauth_user', + array( 'oaau_rid', 'oaau_uid', 'oaau_username', 'oaau_access_token', + 'oaau_access_secret', 'oaau_identify_timestamp' ), + array( 'oaau_username' => $user->getName() ), + __METHOD__ + ); + + if ( !$row ) { + return false; + } else { + return new self( $row->oaau_rid, $row->oaau_uid, $row->oaau_username, + $row->oaau_access_token, $row->oaau_access_secret, + $row->oaau_identify_timestamp ); + } + } + + public function addToDatabase( \DatabaseBase $db ) { + $row = array( + 'oaau_rid' => $this->remoteId, + 'oaau_uid' => $this->userId, + 'oaau_username' => $this->username, + ); + + if ( $this->accessToken ) { + $row += array( + 'oaau_access_token' => $this->accessToken->key, + 'oaau_access_secret' => $this->accessToken->secret, + ); + } + + if ( $this->identifyTS ) { + $row += array( + 'oaau_identify_timestamp' => $db->timestampOrNull( (string)$this->identifyTS ), + ); + } + + $db->insert( + 'oauthauth_user', + $row, + __METHOD__ + ); + } + + public function updateInDatabase( \DatabaseBase $db ) { + if ( !$this->userId > 0 ) { + throw new Exception( 'Error updating External User that isn\'t in the DB' ); + } + $row = array( + 'oaau_rid' => $this->remoteId, + 'oaau_username' => $this->username, + ); + + if ( $this->accessToken ) { + $row += array( + 'oaau_access_token' => $this->accessToken->key, + 'oaau_access_secret' => $this->accessToken->secret, + ); + } + + if ( $this->identifyTS ) { + $row += array( + 'oaau_identify_timestamp' => $db->timestampOrNull( (string)$this->identifyTS ), + ); + } + + $db->update( + 'oauthauth_user', + /* SET */ $row, + /* WHERE */ array( 'oaau_uid' => $this->userId ), + __METHOD__ + ); + + } + + public function removeAccessTokens( \DatabaseBase $db ) { + if ( !$this->userId > 0 ) { + throw new Exception( 'Error updating External User that isn\'t in the DB' ); + } + $db->update( + 'oauthauth_user', + array( + 'oaau_access_token' => '', + 'oaau_access_secret' => '', + ), + array( 'oaau_uid' => $this->userId ), + __METHOD__ + ); + } + + public function getName() { + return $this->username; + } + + public function getLocalId() { + return $this->userId; + } + + public function setLocalId( $uid ) { + $this->userId = $uid; + } + + public function attached() { + return ( $this->userId !== 0 ); + } + + public function setAccessToken( \OAuthToken $accessToken ) { + $this->accessToken = $accessToken; + } + + public function getAccessToken() { + return $this->accessToken; + } + + public function setIdentifyTS( \MWTimestamp $ts ) { + $this->identifyTS = $ts; + } + + public function getIdentifyTS() { + return $this->identifyTS; + } +} diff --git a/utils/Policy.php b/utils/Policy.php new file mode 100644 index 0000000..ef085d1 --- /dev/null +++ b/utils/Policy.php @@ -0,0 +1,37 @@ +<?php + +namespace MediaWiki\Extensions\OAuthAuthentication; + +class Policy { + + public static function policyToEnforce() { + global $wgOAuthAuthenticationUsernameWhitelist, + $wgOAuthAuthenticationGroupWhitelist; + + return ( $wgOAuthAuthenticationUsernameWhitelist !== false + || $wgOAuthAuthenticationGroupWhitelist !== false + ); + } + + /** + * @param $identity jwt identity object + * @return bool true if the user should be allowed according to whitelists. False otherwise. + */ + public static function checkWhitelists( $identity ) { + global $wgOAuthAuthenticationUsernameWhitelist, + $wgOAuthAuthenticationGroupWhitelist; + + return self::checkUserWhitelist( $identity->username, $wgOAuthAuthenticationUsernameWhitelist ) + && self::checkGroupWhitelist( $identity->groups, $wgOAuthAuthenticationGroupWhitelist ); + } + + + private static function checkUserWhitelist( $username, $whitelist ) { + return !$whitelist || in_array( $username, $whitelist ); + } + + private static function checkGroupWhitelist( $groups, $whitelist ) { + return !$whitelist || count( array_intersect( $groups, $whitelist ) ) > 0; + } + +} -- To view, visit https://gerrit.wikimedia.org/r/173303 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I8a51690828ab09c7e49fa53a8c46497c861f58e3 Gerrit-PatchSet: 1 Gerrit-Project: mediawiki/extensions/OAuthAuthentication Gerrit-Branch: master Gerrit-Owner: CSteipp <[email protected]> Gerrit-Reviewer: CSteipp <[email protected]> Gerrit-Reviewer: Siebrand <[email protected]> Gerrit-Reviewer: Springle <[email protected]> _______________________________________________ MediaWiki-commits mailing list [email protected] https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits
