Initialize Project

master
落雨楓 3 years ago
commit 126c1a1337

@ -0,0 +1,6 @@
<?php
$specialPageAliases = [];
$specialPageAliases['en'] = [
'IsekaiOIDCCallback' => ['IsekaiOIDCCallback'],
];

@ -0,0 +1,17 @@
{
"name": "hyperzlib/isekai-oidc",
"type": "mediawiki-extension",
"license": "MIT",
"authors": [
{
"name": "Hyperzlib",
"email": "hyperzlib@outlook.com"
}
],
"require": {
"phpseclib/phpseclib" : "2.0.1"
},
"extra": {
"installer-name": "IsekaiOIDC"
}
}

@ -0,0 +1,60 @@
{
"name": "Isekai OpenID Connect",
"author": "hyperzlib",
"version": "1.1.0",
"url": "https://www.isekai.cn",
"descriptionmsg": "ucenter-desc",
"license-name": "MIT",
"type": "other",
"requires": {
},
"MessagesDirs": {
"IsekaiOIDC": [
"i18n"
]
},
"AutoloadNamespaces": {
"Isekai\\OIDC\\": "includes/"
},
"AutoloadClasses": {
"Jumbojett\\OpenIDConnectClient": "lib/openid-connect-php/OpenIDConnectClient.php",
"Jumbojett\\OpenIDConnectClientException": "lib/openid-connect-php/OpenIDConnectClient.php"
},
"callback": "Isekai\\OIDC\\IsekaiOIDCAuthHooks::onRegistration",
"Hooks": {
"LoadExtensionSchemaUpdates": "Isekai\\OIDC\\IsekaiOIDCAuthHooks::loadExtensionSchemaUpdates",
"BeforePageDisplay": "Isekai\\OIDC\\IsekaiOIDCAuthHooks::onBeforePageDisplay",
"UserLogoutComplete": "Isekai\\OIDC\\IsekaiOIDCAuthHooks::onLogout",
"GetPreferences": "Isekai\\OIDC\\IsekaiOIDCAuthHooks::onGetPreferences",
"PersonalUrls": "Isekai\\OIDC\\IsekaiOIDCAuthHooks::onPersonalUrls",
"ResourceLoaderGetConfigVars": "Isekai\\OIDC\\IsekaiOIDCAuthHooks::onResourceLoaderGetConfigVars"
},
"ExtensionMessagesFiles": {
"IsekaiOIDCAlias": "IsekaiOIDC.alias.php"
},
"SpecialPages": {
"IsekaiOIDCCallback": "Isekai\\OIDC\\SpecialIsekaiOIDCCallback"
},
"ResourceFileModulePaths": {
"localBasePath": "modules",
"remoteExtPath": "IsekaiOIDC/modules"
},
"AuthManagerAutoConfig": {
"primaryauth": {
"IsekaiOIDCAuth": {
"class": "Isekai\\OIDC\\IsekaiOIDCAuth",
"sort": 1
}
}
},
"APIModules": {
"oidcwebhook": "Isekai\\OIDC\\ApiOidcWebhook"
},
"config": {
"IsekaiOIDC": {
"value": null
}
},
"manifest_version": 2
}

@ -0,0 +1,23 @@
{
"isekaioidc-loginbutton": "Isekai Account Login",
"isekaioidc-loginbutton-help": "Login with Isekai Project Account",
"isekaioidc-login-failed": "Unknow error while OIDC",
"apihelp-oidcwebhook-summary": "OpenID Connection Webhook",
"apihelp-oidcwebhook-param-provider": "Provider",
"apihelp-oidcwebhook-param-key": "Secret Key",
"isekaioidc-api-provider-not-supported": "Provider Unsupported",
"isekaioidc-api-key-invalid": "API Key invalid",
"isekaioidc-api-post-body-invalid": "POST Body format invalid",
"isekaioidc-api-user-not-exists": "User not exists",
"isekaioidc-api-cannot-refresh-token": "cannot update Access Token",
"isekaioidc-api-invalid-access-token": "Access Token format invalid",
"isekaioidc-api-cannot-request-api": "Cannot access to API",
"isekaioidc-api-unknow-apimode": "'apimode' in config unsupported",
"prefs-editavatar": "Avatar: ",
"prefs-global-profile": "Global profile: ",
"prefs-help-golbal-profile": "Change avatar, nickname on global account console.",
"btn-idp-profile-settings": "Account Console"
}

@ -0,0 +1,23 @@
{
"isekaioidc-loginbutton": "异世界百科账号登录",
"isekaioidc-loginbutton-help": "使用异世界百科账号登录",
"isekaioidc-login-failed": "无法登录,请求中发生错误",
"apihelp-oidcwebhook-summary": "OpenID Connection Webhook",
"apihelp-oidcwebhook-param-provider": "Provider",
"apihelp-oidcwebhook-param-key": "Secret Key",
"isekaioidc-api-provider-not-supported": "不支持的Provider",
"isekaioidc-api-key-invalid": "API Key错误",
"isekaioidc-api-post-body-invalid": "POST Body格式错误",
"isekaioidc-api-user-not-exists": "用户不存在",
"isekaioidc-api-cannot-refresh-token": "无法更新Access Token",
"isekaioidc-api-invalid-access-token": "Access Token格式不正确",
"isekaioidc-api-cannot-request-api": "无法请求API",
"isekaioidc-api-unknow-apimode": "配置文件中的apimode不支持",
"prefs-editavatar": "头像:",
"prefs-global-profile": "全局用户资料:",
"prefs-help-golbal-profile": "更改头像、昵称等信息",
"btn-idp-profile-settings": "前往用户中心"
}

@ -0,0 +1,96 @@
<?php
namespace Isekai\OIDC;
use ApiBase;
use Wikimedia\ParamValidator\ParamValidator;
use Isekai\OIDC\IsekaiOIDCAuth;
use User;
class ApiOidcWebhook extends ApiBase {
public function __construct( $main, $method ) {
parent::__construct( $main->getMain(), $method );
}
public function execute() {
global $wgIsekaiOIDC;
$queryValues = $this->getRequest()->getQueryValues();
$provider = '';
if (isset($queryValues['provider'])) {
$provider = $queryValues['provider'];
}
if (isset($wgIsekaiOIDC['webhookKey'])) {
if (!isset($queryValues['key']) || $queryValues['key'] !== $wgIsekaiOIDC['webhookKey']) {
$this->addError('isekaioidc-api-key-invalid', '-1', [ 'provider' => $provider ]);
return;
}
}
switch ($provider) {
case 'keycloak':
$this->keycloakCallback();
break;
default:
$this->addError('isekaioidc-api-provider-not-supported', '-1', [ 'provider' => $provider ]);
}
}
private function keycloakCallback() {
if (!$this->getRequest()->wasPosted()) {
return $this->addError('isekaioidc-api-post-body-invalid');
}
$postBody = $this->getRequest()->getRawPostString();
$postData = json_decode($postBody);
if (!$this->getRequest()->wasPosted() || !$postData) {
return $this->addError('isekaioidc-api-post-body-invalid');
}
global $wgIsekaiOIDC;
$realm = $wgIsekaiOIDC['realm'];
$apiMode = isset($wgIsekaiOIDC['apiMode']) ? $wgIsekaiOIDC['apiMode'] : 'oauth';
$eventType = $postData->type;
$subject = $postData->userId;
$eventRealm = $postData->realmId;
if ($eventRealm !== $realm) {
//return $this->addError('isekaioidc-api-realm-not-match');
$this->getResult()->addValue(null, 'webhook', 0);
return;
}
if (!in_array($eventType, ['UPDATE_PROFILE', 'UPDATE_EMAIL'])) {
//return $this->addError('isekaioidc-api-unsupported-event');
$this->getResult()->addValue(null, 'webhook', 0);
return;
}
list($userId, $userName, $accessToken, $refreshToken) = IsekaiOIDCAuth::findUser($subject);
if ($userId) {
$userInfo = $postData->userInfo;
$newProfile = [
'realname' => $userInfo->name,
'email' => $userInfo->email,
'phone' => $userInfo->phone_number,
];
$user = User::newFromId($userId);
IsekaiOIDCAuth::updateUserInfo($user, $newProfile);
}
$this->getResult()->addValue(null, 'webhook', 1);
}
public function getAllowedParams() {
return [
'provider' => [
ParamValidator::PARAM_DEFAULT => null,
ApiBase::PARAM_TYPE => 'text',
],
'key' => [
ParamValidator::PARAM_DEFAULT => null,
ApiBase::PARAM_TYPE => 'text',
]
];
}
}

@ -0,0 +1,583 @@
<?php
namespace Isekai\OIDC;
use Exception;
use Jumbojett\OpenIDConnectClient;
use MediaWiki\Auth\AbstractPrimaryAuthenticationProvider;
use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\AuthManager;
use MediaWiki\MediaWikiServices;
use MediaWiki\Session\SessionManager;
use StatusValue;
use User;
use Title;
use SpecialPage;
use Sanitizer;
use RequestContext;
use WebRequest;
class IsekaiOIDCAuth extends AbstractPrimaryAuthenticationProvider {
private $subject;
const LOG_TAG = 'Isekai OIDC';
const OIDC_TABLE = 'isekai_oidc';
const OIDC_FAKE_MAILDOMAIN = 'isekai.cn';
const OIDC_SUBJECT_SESSION_KEY = 'IsekaiOpenIDConnectSubject';
const USERNAME_SESSION_KEY = 'IsekaiOpenIDConnectUsername';
const REALNAME_SESSION_KEY = 'IsekaiOpenIDConnectRealname';
const EMAIL_SESSION_KEY = 'IsekaiOpenIDConnectEmail';
const PHONE_SESSION_KEY = 'IsekaiOpenIDConnectPhone';
//const BIO_SESSION_KEY = 'IsekaiOpenIDConnectBio';
const ACCESS_TOKEN_SESSION_KEY = 'IsekaiOpenIDConnectAccessToken';
const REFRESH_TOKEN_SESSION_KEY = 'IsekaiOpenIDConnectRefreshToken';
/**
* Start an authentication flow
* @inheritDoc
*/
public function beginPrimaryAuthentication( array $reqs ) {
$oidc = self::getOpenIDConnectClient();
$request = RequestContext::getMain()->getRequest();
$returnTo = $request->getVal('returnto');
$returnToQuery = $request->getVal('returntoquery');
$session = SessionManager::getGlobalSession();
$authManager = MediaWikiServices::getInstance()->getAuthManager();
$authManager->setAuthenticationSessionData('AuthManagerSpecialPage:return:IsekaiOIDCCallback', [
'title' => 1,
'authAction' => 'IsekaiOIDCAuth',
'wpLoginToken' => $session->getToken('AuthManagerSpecialPage:IsekaiOIDCCallback'),
'wpRemember' => 1,
'returnto' => $returnTo,
'returntoquery' => $returnToQuery,
]);
$url = $oidc->getAuthorizationUrl();
return AuthenticationResponse::newRedirect([
new IsekaiOIDCAuthBeginAuthenticationRequest(),
], $url);
}
/**
* Continue an authentication flow
* @inheritDoc
*/
public function continuePrimaryAuthentication( array $reqs ) {
global $wgIsekaiOIDC;
$config = $wgIsekaiOIDC;
$oidc =self::getOpenIDConnectClient();
if ($oidc->authenticate()) {
$accessToken = $oidc->getAccessToken();
$refreshToken = $oidc->getRefreshToken();
$payload = $oidc->getAccessTokenPayload();
$realname = $oidc->requestUserInfo( 'name' );
$email = $oidc->requestUserInfo( 'email' );
$phone = $oidc->requestUserInfo( 'phone_number' );
//$bio = $oidc->requestUserInfo( 'bio' );
$this->subject = $oidc->requestUserInfo( 'sub' );
// set rememberMe
if ( isset( $payload->remember_me ) && $payload->remember_me ) {
$GLOBALS['wgIsekaiOIDCRemember'] = true;
}
if ( method_exists( MediaWikiServices::class, 'getAuthManager' ) ) {
// MediaWiki 1.35+
$authManager = MediaWikiServices::getInstance()->getAuthManager();
} else {
$authManager = AuthManager::singleton();
}
$request = RequestContext::getMain()->getRequest();
$session = $request->getSession();
$session->clear('AuthManager::AutoCreateBlacklist'); // 防止缓存检测
wfDebugLog( self::LOG_TAG, 'Real name: ' . $realname . ', Phone: ' . $phone .
', Email: ' . $email . ', Subject: ' . $this->subject . PHP_EOL );
$authManager->setAuthenticationSessionData(self::EMAIL_SESSION_KEY, $email);
$authManager->setAuthenticationSessionData(self::PHONE_SESSION_KEY, $phone);
$authManager->setAuthenticationSessionData(self::REALNAME_SESSION_KEY, $realname);
//$authManager->setAuthenticationSessionData(self::BIO_SESSION_KEY, $bio);
$authManager->setAuthenticationSessionData(self::ACCESS_TOKEN_SESSION_KEY, $accessToken);
$authManager->setAuthenticationSessionData(self::REFRESH_TOKEN_SESSION_KEY, $refreshToken);
list( $id, $username ) =
$this->findUser( $this->subject );
if ( $id !== null ) {
wfDebugLog( self::LOG_TAG,
'Found user with matching subject.' . PHP_EOL );
$authManager->setAuthenticationSessionData(self::USERNAME_SESSION_KEY, $username);
$this->updateUserInfo($username);
return AuthenticationResponse::newPass($username);
}
wfDebugLog( self::LOG_TAG,
'No user found with matching subject.' . PHP_EOL );
if ( isset($config['migrateBy']) && $config['migrateBy'] === 'email' ) {
wfDebugLog( self::LOG_TAG, 'Checking for email migration.' .
PHP_EOL );
list( $id, $username ) = $this->getMigratedIdByEmail( $email );
if ( $id !== null ) {
$this->saveExtraAttributes( $id );
wfDebugLog( self::LOG_TAG, 'Migrated user ' . $username .
' by email: ' . $email . '.' . PHP_EOL );
$authManager->setAuthenticationSessionData(self::USERNAME_SESSION_KEY, $username);
$this->updateUserInfo($username);
return AuthenticationResponse::newPass($username);
}
}
$preferred_username = $this->getPreferredUsername( $config, $oidc,
$realname, $email );
wfDebugLog( self::LOG_TAG, 'Preferred username: ' .
$preferred_username . PHP_EOL );
$authManager->setAuthenticationSessionData(self::USERNAME_SESSION_KEY, $preferred_username);
if ( !isset($config['migrateBy']) || $config['migrateBy'] === 'username' ) {
wfDebugLog( self::LOG_TAG, 'Checking for username migration.' .
PHP_EOL );
$id = $this->getMigratedIdByUserName( $preferred_username );
if ( $id !== null ) {
$this->saveExtraAttributes( $id );
wfDebugLog( self::LOG_TAG, 'Migrated user by username: ' .
$preferred_username . '.' . PHP_EOL );
$username = $preferred_username;
$authManager->setAuthenticationSessionData(self::USERNAME_SESSION_KEY, $username);
$this->updateUserInfo($username);
return AuthenticationResponse::newPass($username);
}
}
$username = self::getAvailableUsername( $preferred_username,
$realname, $email );
wfDebugLog( self::LOG_TAG, 'Available username: ' .
$username . PHP_EOL );
$authManager->setAuthenticationSessionData(
self::OIDC_SUBJECT_SESSION_KEY, $this->subject );
$authManager->setAuthenticationSessionData(self::USERNAME_SESSION_KEY, $username);
return AuthenticationResponse::newPass($username);
} else {
return AuthenticationResponse::newFail(wfMessage('isekaioidc-login-failed'));
}
}
/**
* Determine whether a property can change
* @inheritDoc
*/
public function providerAllowsPropertyChange( $property ) {
if (in_array($property, ['emailaddress', 'realname'])) {
return false;
}
return true;
}
/**
* @inheritDoc
*/
public function autoCreatedAccount( $user, $source ) {
// user created
if ($user) {
$this->updateUserInfo($user);
$this->saveExtraAttributes($user->getId());
}
}
/**
* Test whether the named user exists
* @inheritDoc
*/
public function testUserExists( $username, $flags = User::READ_NORMAL ) {
return false;
}
/**
* Validate a change of authentication data (e.g. passwords)
* @inheritDoc
*/
public function providerAllowsAuthenticationDataChange(
AuthenticationRequest $req, $checkData = true ) {
return StatusValue::newGood( 'ignored' );
}
/**
* Fetch the account-creation type
* @inheritDoc
*/
public function accountCreationType() {
return self::TYPE_LINK;
}
/**
* Start an account creation flow
* @inheritDoc
*/
public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) {
return AuthenticationResponse::newAbstain();
}
/**
* Change or remove authentication data (e.g. passwords)
* @inheritDoc
*/
public function providerChangeAuthenticationData( AuthenticationRequest $req ) {
}
public static function getOpenIDConnectClient() {
global $wgIsekaiOIDC;
if (!is_array($wgIsekaiOIDC)) {
wfDebugLog( self::LOG_TAG, 'wgIsekaiOIDC not set' .
PHP_EOL );
throw new Exception('wgIsekaiOIDC not set');
}
$config = $wgIsekaiOIDC;
if (!isset($config['endpoint']) || !isset($config['clientID']) || !isset($config['clientSecret'])) {
wfDebugLog( self::LOG_TAG, 'wgIsekaiOIDC not valid' .
PHP_EOL );
throw new Exception('wgIsekaiOIDC not valid');
}
$endpoint = $config['endpoint'];
$clientID = $config['clientID'];
$clientSecret = $config['clientSecret'];
$oidc = new OpenIDConnectClient($endpoint, $clientID, $clientSecret);
$redirectURL = $redirectURL = SpecialPage::getTitleFor('IsekaiOIDCCallback')->getFullURL();
$oidc->setRedirectURL( $redirectURL );
wfDebugLog( self::LOG_TAG, 'Redirect URL: ' . $redirectURL );
if ( isset( $_REQUEST['forcelogin'] ) ) {
$oidc->addAuthParam( [ 'prompt' => 'login' ] );
}
if ( isset( $config['authparam'] ) &&
is_array( $config['authparam'] ) ) {
$oidc->addAuthParam( $config['authparam'] );
}
if ( isset( $config['scope'] ) ) {
$scope = $config['scope'];
if ( is_array( $scope ) ) {
foreach ( $scope as $s ) {
$oidc->addScope( $s );
}
} else {
$oidc->addScope( $scope );
}
}
if ( isset( $config['proxy'] ) ) {
$oidc->setHttpProxy( $config['proxy'] );
}
if ( isset( $config['verifyHost'] ) ) {
$oidc->setVerifyHost( $config['verifyHost'] );
}
if ( isset( $config['verifyPeer'] ) ) {
$oidc->setVerifyPeer( $config['verifyPeer'] );
}
return $oidc;
}
/**
* @inheritDoc
*/
public function getAuthenticationRequests( $action, array $options ) {
switch ( $action ) {
case AuthManager::ACTION_LOGIN:
return [
new IsekaiOIDCAuthBeginAuthenticationRequest()
];
default:
return [];
}
}
/**
* 更新用户的个人信息
* @param User|string $user 用户
* @param array|null $data 用户信息
*/
public static function updateUserInfo($user, $data = null) {
if (is_string($user)) {
$user = User::newFromName($user);
}
if ($data) {
$accessToken = isset($data['accessToken']) ? $data['accessToken'] : null;
$refreshToken = isset($data['refreshToken']) ? $data['refreshToken'] : null;
$newEmail = isset($data['email']) ? $data['email'] : null;
$newRealName = isset($data['realname']) ? $data['realname'] : null;
$newPhone = isset($data['phone']) ? $data['phone'] : null;
} else {
if ( method_exists( MediaWikiServices::class, 'getAuthManager' ) ) {
// MediaWiki 1.35+
$authManager = MediaWikiServices::getInstance()->getAuthManager();
} else {
$authManager = AuthManager::singleton();
}
$accessToken = $authManager->getAuthenticationSessionData(self::ACCESS_TOKEN_SESSION_KEY);
$refreshToken = $authManager->getAuthenticationSessionData(self::REFRESH_TOKEN_SESSION_KEY);
$newEmail = $authManager->getAuthenticationSessionData(self::EMAIL_SESSION_KEY);
$newRealName = $authManager->getAuthenticationSessionData(self::REALNAME_SESSION_KEY);
$newPhone = $authManager->getAuthenticationSessionData(self::PHONE_SESSION_KEY);
}
if (!$newEmail && $newPhone) { // 如果只设置了手机号,没有设置邮箱地址,则设置一个虚构的电子邮箱
$newEmail = self::punycodeEnc($user->getName()) . '@' . self::OIDC_FAKE_MAILDOMAIN;
}
if ($accessToken) {
wfDebugLog( self::LOG_TAG,
'update access token for: ' . $user->getId() . '.' .
PHP_EOL );
$dbw = wfGetDB( DB_MASTER );
$dbw->upsert(
self::OIDC_TABLE,
[
'oidc_user' => $user->getId(),
'access_token' => $accessToken,
'refresh_token' => $refreshToken
],
[
[ 'oidc_user' ]
],
[
'access_token' => $accessToken,
'refresh_token' => $refreshToken
],
__METHOD__
);
}
$modified = false;
if ($newEmail && $newEmail != $user->mEmail && Sanitizer::validateEmail($newEmail)) {
$user->mEmail = $newEmail;
$user->confirmEmail();
$modified = true;
}
if ($newRealName && $newRealName != $user->mRealName) {
$user->mRealName = $newRealName;
$modified = true;
}
if ($modified) {
$user->saveSettings();
}
}
/**
* @since 1.0
*
* @param int $id user id
*/
public function saveExtraAttributes( $id ) {
if ( method_exists( MediaWikiServices::class, 'getAuthManager' ) ) {
// MediaWiki 1.35+
$authManager = MediaWikiServices::getInstance()->getAuthManager();
} else {
$authManager = AuthManager::singleton();
}
if ( $this->subject === null ) {
$this->subject = $authManager->getAuthenticationSessionData(
self::OIDC_SUBJECT_SESSION_KEY );
$authManager->removeAuthenticationSessionData(
self::OIDC_SUBJECT_SESSION_KEY );
}
$dbw = wfGetDB( DB_MASTER );
$dbw->upsert(
self::OIDC_TABLE,
[
'oidc_user' => $id,
'oidc_subject' => $this->subject
],
[
[ 'oidc_user' ]
],
[
'oidc_subject' => $this->subject
],
__METHOD__
);
}
public static function findUser( $subject ) {
$dbr = wfGetDB( DB_REPLICA );
$row = $dbr->selectRow(
[
'user',
self::OIDC_TABLE
],
[
'user_id',
'user_name',
'access_token',
'refresh_token'
],
[
'oidc_subject' => $subject
],
__METHOD__,
[],
[
self::OIDC_TABLE => [ 'JOIN', [ 'user_id=oidc_user' ] ]
]
);
if ( $row === false ) {
return [ null, null, null, null ];
} else {
return [ $row->user_id, $row->user_name, $row->access_token, $row->refresh_token ];
}
}
public static function findOidcDataByUserId( $userId ) {
$dbr = wfGetDB( DB_REPLICA );
$row = $dbr->selectRow(
[
self::OIDC_TABLE
],
[
'oidc_user',
'oidc_subject',
'access_token',
'refresh_token'
],
[
'oidc_user' => $userId
],
__METHOD__
);
if ( $row === false ) {
return [ null, null, null ];
} else {
return [ $row->oidc_subject, $row->access_token, $row->refresh_token ];
}
}
private static function getPreferredUsername( $config, $oidc, $realname, $email ) {
if ( isset( $config['preferred_username'] ) ) {
wfDebugLog( self::LOG_TAG, 'Using ' . $config['preferred_username'] .
' attribute for preferred username.' . PHP_EOL );
$preferred_username =
$oidc->requestUserInfo( $config['preferred_username'] );
} else {
$preferred_username = $oidc->requestUserInfo( 'preferred_username' );
}
if ( strlen( $preferred_username ) > 0 ) {
// do nothing
} elseif ( strlen( $realname ) > 0 && isset($config['migrateBy']) && $config['migrateBy'] === 'realname' ) {
$preferred_username = $realname;
} elseif ( strlen( $email ) > 0 && isset($config['migrateBy']) && $config['migrateBy'] === 'email' ) {
$pos = strpos( $email, '@' );
if ( $pos !== false && $pos > 0 ) {
$preferred_username = substr( $email, 0, $pos );
} else {
$preferred_username = $email;
}
} else {
return null;
}
$nt = Title::makeTitleSafe( NS_USER, $preferred_username );
if ( $nt === null ) {
return null;
}
return $nt->getText();
}
private static function getMigratedIdByUserName( $username ) {
$nt = Title::makeTitleSafe( NS_USER, $username );
if ( $nt === null ) {
wfDebugLog( self::LOG_TAG,
'Invalid preferred username for migration: ' . $username . '.' .
PHP_EOL );
return null;
}
$username = $nt->getText();
$dbr = wfGetDB( DB_REPLICA );
$row = $dbr->selectRow(
[
'user',
self::OIDC_TABLE
],
[
'user_id'
],
[
'user_name' => $username,
'oidc_user' => null
],
__METHOD__,
[],
[
self::OIDC_TABLE => [ 'LEFT JOIN', [ 'user_id=oidc_user' ] ]
]
);
if ( $row !== false ) {
return $row->user_id;
}
return null;
}
private static function getMigratedIdByEmail( $email ) {
wfDebugLog( self::LOG_TAG, 'Matching user to email ' . $email . '.' .
PHP_EOL );
$dbr = wfGetDB( DB_REPLICA );
$row = $dbr->selectRow(
[
'user',
self::OIDC_TABLE
],
[
'user_id',
'user_name',
'oidc_user'
],
[
'user_email' => $email
],
__METHOD__,
[
// if multiple matching accounts, use the oldest one
'ORDER BY' => 'user_registration'
],
[
self::OIDC_TABLE => [ 'LEFT JOIN', [ 'user_id=oidc_user' ] ]
]
);
if ( $row !== false && $row->oidc_user === null ) {
return [ $row->user_id, $row->user_name ];
}
return [ null, null ];
}
private static function getAvailableUsername( $preferred_username ) {
if ( $preferred_username === null ) {
$preferred_username = 'User';
}
if ( User::idFromName( $preferred_username ) === null ) {
return $preferred_username;
}
$count = 1;
while ( User::idFromName( $preferred_username . $count ) !== null ) {
$count++;
}
return $preferred_username . $count;
}
protected static function punycodeEnc($str){
$punycode = new PunyCode();
return $punycode->encode($str);
}
}

@ -0,0 +1,27 @@
<?php
namespace Isekai\OIDC;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Auth\ButtonAuthenticationRequest;
class IsekaiOIDCAuthBeginAuthenticationRequest extends ButtonAuthenticationRequest {
public function __construct() {
parent::__construct(
'isekaioidclogin',
wfMessage( 'isekaioidc-loginbutton' ),
wfMessage( 'isekaioidc-loginbutton-help' ),
true );
}
/**
* Returns field information.
* @return array field information
*/
public function getFieldInfo() {
if ( $this->action !== AuthManager::ACTION_LOGIN ) {
return [];
}
return parent::getFieldInfo();
}
}

@ -0,0 +1,170 @@
<?php
namespace Isekai\OIDC;
use Exception;
use Html;
use MediaWiki\MediaWikiServices;
use User;
use OutputPage;
use RequestContext;
use MediaWiki\Session\SessionManager;
use Title;
class IsekaiOIDCAuthHooks {
const SYNLOGIN_SESSIONKEY = 'IsekaiOIDCSyncLogin';
const SYNLOGOUT_SESSIONKEY = 'IsekaiOIDCSyncLogout';
public static function onRegistration() {
$passwordProviders = [
'MediaWiki\Auth\LocalPasswordPrimaryAuthenticationProvider',
'MediaWiki\Auth\TemporaryPasswordPrimaryAuthenticationProvider'
];
$providers = $GLOBALS['wgAuthManagerAutoConfig'];
if ( isset( $providers['primaryauth'] ) ) {
$primaries = $providers['primaryauth'];
foreach ( $primaries as $key => $provider ) {
if ( in_array( $provider['class'], $passwordProviders ) ) {
unset( $GLOBALS['wgAuthManagerAutoConfig']['primaryauth'][$key] );
}
}
}
}
/**
* Implements LoadExtensionSchemaUpdates hook.
*
* @param DatabaseUpdater $updater
*/
public static function loadExtensionSchemaUpdates( $updater ) {
$dir = dirname(__DIR__) . '/sql/';
$type = $updater->getDB()->getType();
$updater->addExtensionTable( 'isekai_oidc',
$dir . $type . '/AddTable.sql' );
}
/**
* Implements PageBeforeDisplay hook.
* @param OutputPage $out
*/
public static function onBeforePageDisplay($out) {
global $wgIsekaiOIDC;
if (isset($wgIsekaiOIDC['syncLogout']) && $wgIsekaiOIDC['syncLogout']) {
$title = $out->getTitle();
$request = RequestContext::getMain()->getRequest();
$needLogout = $request->getCookie(self::SYNLOGOUT_SESSIONKEY);
$isLogoutPage = $title != null && $title->isSpecial('Userlogout');
if ($isLogoutPage || $needLogout) { // 需要重定向到用户退出页面
$old_name = $request->getCookie('UserName');
wfDebugLog('IsekaiOIDC', 'oldUser: ' . $old_name);
$oldUser = User::newFromName($old_name);
if ( $oldUser === false ) {
return;
}
list($subject, $accessToken, $refreshToken) = IsekaiOIDCAuth::findOidcDataByUserId($oldUser->getId());
if ($subject != null) {
$redirectUri = '';
if ($isLogoutPage) {
$returnto = $request->getVal('returnto');
$returntoquery = $request->getVal('returntoquery');
if ($returnto) {
$returnto = Title::newFromText($returnto);
} else {
$returnto = Title::newMainPage();
}
if (!$returnto) {
$returnto = Title::newMainPage();
}
$redirectUri = Title::newMainPage()->getFullURL($returntoquery);
} else if ($title != null) {
$redirectUri = $title->getFullURL();
} else {
$redirectUri = Title::newMainPage()->getFullURL();
}
$url = $wgIsekaiOIDC['endpoint'] . 'protocol/openid-connect/logout?' .
http_build_query([ 'redirect_uri' => $redirectUri ]);
wfDebugLog('IsekaiOIDC', 'logout url: ' . $url);
$response = $request->response();
$response->clearCookie(self::SYNLOGOUT_SESSIONKEY);
$response->header('Location: ' . $url);
}
}
}
}
public static function onLogout($user, &$injected_html) {
$request = RequestContext::getMain()->getRequest();
$request->response()->setCookie(self::SYNLOGOUT_SESSIONKEY, 1);
}
public static function onGetPreferences(\User $user, &$preferences) {
global $wgIsekaiOIDC;
$profileUrl = $wgIsekaiOIDC['endpoint'] . 'account/';
$referrer = $wgIsekaiOIDC['clientID'];
$referrerUri = Title::newFromText('Special:Preferences')->getCanonicalURL();
$profileUrl .= '?' . http_build_query([
'referrer' => $referrer,
'referrer_uri' => $referrerUri
]);
$preferences['global-profile'] = array(
'type' => 'info',
'raw' => true,
'label-message' => 'prefs-global-profile',
'help-message' => 'prefs-help-golbal-profile',
'default' => strval(new \OOUI\ButtonWidget([
'id' => 'global-profile-link',
'href' => $profileUrl,
'label' => wfMessage('btn-idp-profile-settings')->text(),
])),
'section' => 'personal/info',
);
if (isset($wgIsekaiOIDC['avatarUrl'])) {
list($subject, $accessToken, $refreshToken) = IsekaiOIDCAuth::findOidcDataByUserId($user->getId());
$avatarLink = str_replace(['{openid}', '{username}'], [urlencode($subject), urlencode($user->getName())], $wgIsekaiOIDC['avatarUrl']);
$preferences['editavatar'] = array(
'type' => 'info',
'raw' => true,
'label-message' => 'prefs-editavatar',
'default' => Html::openElement('a', [
'href' => $profileUrl,
]) .
'<img src="' . $avatarLink . '" width="64" height="64"></img>' .
Html::closeElement('a'),
'section' => 'personal/info',
);
}
return true;
}
/**
* @param array &$personal_urls
* @param Title $title
* @param \SkinTemplate $skin
*/
public static function onPersonalUrls(&$personal_urls, $title, $skin) {
if (isset($personal_urls['createaccount'])) {
unset($personal_urls['createaccount']);
}
if (isset($personal_urls['login'])) {
$personal_urls['login']['text'] = wfMessage('nav-login-createaccount')->text();
}
}
/**
* @param array &$personal_urls
* @param string $skin
* @param \Config $config
*/
public static function onResourceLoaderGetConfigVars(&$vars, $skin, $config) {
global $wgIsekaiOIDC;
if (isset($wgIsekaiOIDC['avatarUrl'])) {
$vars['wgAvatarTemplate'] = $wgIsekaiOIDC['avatarUrl'];
}
return true;
}
}

@ -0,0 +1,355 @@
<?php
namespace Isekai\OIDC;
/**
* Punycode implementation as described in RFC 3492
*
* @link http://tools.ietf.org/html/rfc3492
*/
class PunyCode {
/**
* Bootstring parameter values
*
*/
const BASE = 36;
const TMIN = 1;
const TMAX = 26;
const SKEW = 38;
const DAMP = 700;
const INITIAL_BIAS = 72;
const INITIAL_N = 128;
const PREFIX = 'xn--';
const DELIMITER = '-';
/**
* Encode table
*
* @param array
*/
protected static $encodeTable = array(
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l',
'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x',
'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
);
/**
* Decode table
*
* @param array
*/
protected static $decodeTable = array(
'a' => 0, 'b' => 1, 'c' => 2, 'd' => 3, 'e' => 4, 'f' => 5,
'g' => 6, 'h' => 7, 'i' => 8, 'j' => 9, 'k' => 10, 'l' => 11,
'm' => 12, 'n' => 13, 'o' => 14, 'p' => 15, 'q' => 16, 'r' => 17,
's' => 18, 't' => 19, 'u' => 20, 'v' => 21, 'w' => 22, 'x' => 23,
'y' => 24, 'z' => 25, '0' => 26, '1' => 27, '2' => 28, '3' => 29,
'4' => 30, '5' => 31, '6' => 32, '7' => 33, '8' => 34, '9' => 35
);
/**
* Character encoding
*
* @param string
*/
protected $encoding;
/**
* Constructor
*
* @param string $encoding Character encoding
*/
public function __construct($encoding = 'UTF-8')
{
$this->encoding = $encoding;
}
/**
* Encode a domain to its Punycode version
*
* @param string $input Domain name in Unicode to be encoded
* @return string Punycode representation in ASCII
*/
public function encode($input)
{
$input = mb_strtolower($input, $this->encoding);
$parts = explode('.', $input);
foreach ($parts as &$part) {
$length = strlen($part);
if ($length < 1) {
throw new \Exception(sprintf('The length of any one label is limited to between 1 and 63 octets, but %s given.', $length));
}
$part = $this->encodePart($part);
}
$output = implode('.', $parts);
$length = strlen($output);
if ($length > 255) {
throw new \Exception(sprintf('A full domain name is limited to 255 octets (including the separators), %s given.', $length));
}
return $output;
}
/**
* Encode a part of a domain name, such as tld, to its Punycode version
*
* @param string $input Part of a domain name
* @return string Punycode representation of a domain part
*/
protected function encodePart($input)
{
$codePoints = $this->listCodePoints($input);
$n = static::INITIAL_N;
$bias = static::INITIAL_BIAS;
$delta = 0;
$h = $b = count($codePoints['basic']);
$output = '';
foreach ($codePoints['basic'] as $code) {
$output .= $this->codePointToChar($code);
}
if ($input === $output) {
return $output;
}
if ($b > 0) {
$output .= static::DELIMITER;
}
$codePoints['nonBasic'] = array_unique($codePoints['nonBasic']);
sort($codePoints['nonBasic']);
$i = 0;
$length = mb_strlen($input, $this->encoding);
while ($h < $length) {
$m = $codePoints['nonBasic'][$i++];
$delta = $delta + ($m - $n) * ($h + 1);
$n = $m;
foreach ($codePoints['all'] as $c) {
if ($c < $n || $c < static::INITIAL_N) {
$delta++;
}
if ($c === $n) {
$q = $delta;
for ($k = static::BASE;; $k += static::BASE) {
$t = $this->calculateThreshold($k, $bias);
if ($q < $t) {
break;
}
$code = $t + (($q - $t) % (static::BASE - $t));
$output .= static::$encodeTable[$code];
$q = ($q - $t) / (static::BASE - $t);
}
$output .= static::$encodeTable[$q];
$bias = $this->adapt($delta, $h + 1, ($h === $b));
$delta = 0;
$h++;
}
}
$delta++;
$n++;
}
$out = static::PREFIX . $output;
$length = strlen($out);
if ($length > 63 || $length < 1) {
throw new \Exception(sprintf('The length of any one label is limited to between 1 and 63 octets, but %s given.', $length));
}
return $out;
}
/**
* Decode a Punycode domain name to its Unicode counterpart
*
* @param string $input Domain name in Punycode
* @return string Unicode domain name
*/
public function decode($input)
{
$input = strtolower($input);
$parts = explode('.', $input);
foreach ($parts as &$part) {
$length = strlen($part);
if ($length > 63 || $length < 1) {
throw new \Exception(sprintf('The length of any one label is limited to between 1 and 63 octets, but %s given.', $length));
}
if (strpos($part, static::PREFIX) !== 0) {
continue;
}
$part = substr($part, strlen(static::PREFIX));
$part = $this->decodePart($part);
}
$output = implode('.', $parts);
$length = strlen($output);
if ($length > 255) {
throw new \Exception(sprintf('A full domain name is limited to 255 octets (including the separators), %s given.', $length));
}
return $output;
}
/**
* Decode a part of domain name, such as tld
*
* @param string $input Part of a domain name
* @return string Unicode domain part
*/
protected function decodePart($input)
{
$n = static::INITIAL_N;
$i = 0;
$bias = static::INITIAL_BIAS;
$output = '';
$pos = strrpos($input, static::DELIMITER);
if ($pos !== false) {
$output = substr($input, 0, $pos++);
} else {
$pos = 0;
}
$outputLength = strlen($output);
$inputLength = strlen($input);
while ($pos < $inputLength) {
$oldi = $i;
$w = 1;
for ($k = static::BASE;; $k += static::BASE) {
$digit = static::$decodeTable[$input[$pos++]];
$i = $i + ($digit * $w);
$t = $this->calculateThreshold($k, $bias);
if ($digit < $t) {
break;
}
$w = $w * (static::BASE - $t);
}
$bias = $this->adapt($i - $oldi, ++$outputLength, ($oldi === 0));
$n = $n + (int) ($i / $outputLength);
$i = $i % ($outputLength);
$output = mb_substr($output, 0, $i, $this->encoding) . $this->codePointToChar($n) . mb_substr($output, $i, $outputLength - 1, $this->encoding);
$i++;
}
return $output;
}
/**
* Calculate the bias threshold to fall between TMIN and TMAX
*
* @param integer $k
* @param integer $bias
* @return integer
*/
protected function calculateThreshold($k, $bias)
{
if ($k <= $bias + static::TMIN) {
return static::TMIN;
} elseif ($k >= $bias + static::TMAX) {
return static::TMAX;
}
return $k - $bias;
}
/**
* Bias adaptation
*
* @param integer $delta
* @param integer $numPoints
* @param boolean $firstTime
* @return integer
*/
protected function adapt($delta, $numPoints, $firstTime)
{
$delta = (int) (
($firstTime)
? $delta / static::DAMP
: $delta / 2
);
$delta += (int) ($delta / $numPoints);
$k = 0;
while ($delta > ((static::BASE - static::TMIN) * static::TMAX) / 2) {
$delta = (int) ($delta / (static::BASE - static::TMIN));
$k = $k + static::BASE;
}
$k = $k + (int) (((static::BASE - static::TMIN + 1) * $delta) / ($delta + static::SKEW));
return $k;
}
/**
* List code points for a given input
*
* @param string $input
* @return array Multi-dimension array with basic, non-basic and aggregated code points
*/
protected function listCodePoints($input)
{
$codePoints = array(
'all' => array(),
'basic' => array(),
'nonBasic' => array(),
);
$length = mb_strlen($input, $this->encoding);
for ($i = 0; $i < $length; $i++) {
$char = mb_substr($input, $i, 1, $this->encoding);
$code = $this->charToCodePoint($char);
if ($code < 128) {
$codePoints['all'][] = $codePoints['basic'][] = $code;
} else {
$codePoints['all'][] = $codePoints['nonBasic'][] = $code;
}
}
return $codePoints;
}
/**
* Convert a single or multi-byte character to its code point
*
* @param string $char
* @return integer
*/
protected function charToCodePoint($char)
{
$code = ord($char[0]);
if ($code < 128) {
return $code;
} elseif ($code < 224) {
return (($code - 192) * 64) + (ord($char[1]) - 128);
} elseif ($code < 240) {
return (($code - 224) * 4096) + ((ord($char[1]) - 128) * 64) + (ord($char[2]) - 128);
} else {
return (($code - 240) * 262144) + ((ord($char[1]) - 128) * 4096) + ((ord($char[2]) - 128) * 64) + (ord($char[3]) - 128);
}
}
/**
* Convert a code point to its single or multi-byte character
*
* @param integer $code
* @return string
*/
protected function codePointToChar($code)
{
if ($code <= 0x7F) {
return chr($code);
} elseif ($code <= 0x7FF) {
return chr(($code >> 6) + 192) . chr(($code & 63) + 128);
} elseif ($code <= 0xFFFF) {
return chr(($code >> 12) + 224) . chr((($code >> 6) & 63) + 128) . chr(($code & 63) + 128);
} else {
return chr(($code >> 18) + 240) . chr((($code >> 12) & 63) + 128) . chr((($code >> 6) & 63) + 128) . chr(($code & 63) + 128);
}
}
}

@ -0,0 +1,131 @@
<?php
namespace Isekai\OIDC;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Logger\LoggerFactory;
use LoginSignupSpecialPage;
use LoginHelper;
class SpecialIsekaiOIDCCallback extends LoginSignupSpecialPage {
protected static $allowedActions = [
AuthManager::ACTION_LOGIN,
AuthManager::ACTION_LOGIN_CONTINUE
];
protected static $messages = [
'authform-newtoken' => 'nocookiesforlogin',
'authform-notoken' => 'sessionfailure',
'authform-wrongtoken' => 'sessionfailure',
];
public function __construct() {
parent::__construct( 'IsekaiOIDCCallback' );
}
public function doesWrites() {
return true;
}
protected function getLoginSecurityLevel() {
return false;
}
protected function getDefaultAction( $subPage ) {
return AuthManager::ACTION_LOGIN;
}
public function getDescription() {
return $this->msg( 'login' )->text();
}
public function setHeaders() {
// override the page title if we are doing a forced reauthentication
parent::setHeaders();
if ( $this->securityLevel && $this->getUser()->isLoggedIn() ) {
$this->getOutput()->setPageTitle( $this->msg( 'login-security' ) );
}
}
protected function isSignup() {
return false;
}
/**
* Run any hooks registered for logins, then HTTP redirect to
* $this->mReturnTo (or Main Page if that's undefined). Formerly we had a
* nice message here, but that's really not as useful as just being sent to
* wherever you logged in from. It should be clear that the action was
* successful, given the lack of error messages plus the appearance of your
* name in the upper right.
* @param bool $direct True if the action was successful just now; false if that happened
* pre-redirection (so this handler was called already)
* @param StatusValue|null $extraMessages
*/
protected function successfulAction( $direct = false, $extraMessages = null ) {
global $wgSecureLogin, $wgIsekaiOIDCRemember;
$user = $this->targetUser ?: $this->getUser();
$session = $this->getRequest()->getSession();
if ( $wgIsekaiOIDCRemember ) {
$session->setRememberUser(true);
}
if ( $direct ) {
$user->touch();
$this->clearToken();
if ( $user->requiresHTTPS() ) {
$this->mStickHTTPS = true;
}
$session->setForceHTTPS( $wgSecureLogin && $this->mStickHTTPS );
// If the user does not have a session cookie at this point, they probably need to
// do something to their browser.
if ( !$this->hasSessionCookie() ) {
$this->mainLoginForm( [ /*?*/ ], $session->getProvider()->whyNoSession() );
// TODO something more specific? This used to use nocookieslogin
return;
}
}
# Run any hooks; display injected HTML if any, else redirect
$injected_html = '';
$this->getHookRunner()->onUserLoginComplete(
$user, $injected_html, $direct );
if ( $injected_html !== '' || $extraMessages ) {
$this->showSuccessPage( 'success', $this->msg( 'loginsuccesstitle' ),
'loginsuccess', $injected_html, $extraMessages );
} else {
$helper = new LoginHelper( $this->getContext() );
$helper->showReturnToPage( 'successredirect', $this->mReturnTo, $this->mReturnToQuery,
$this->mStickHTTPS );
}
}
protected function getToken() {
return $this->getRequest()->getSession()->getToken( 'AuthManagerSpecialPage:IsekaiOIDCCallback' );
}
protected function clearToken() {
return $this->getRequest()->getSession()->resetToken( 'AuthManagerSpecialPage:IsekaiOIDCCallback' );
}
protected function getTokenName() {
return 'wpLoginToken';
}
protected function getGroupName() {
return 'login';
}
protected function logAuthResult( $success, $status = null ) {
LoggerFactory::getInstance( 'authevents' )->info( 'Login attempt', [
'event' => 'login',
'successful' => $success,
'status' => $status,
] );
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,7 @@
CREATE TABLE /*_*/isekai_oidc (
oidc_user INT UNSIGNED PRIMARY KEY NOT NULL,
oidc_subject VARCHAR(255) NOT NULL,
access_token TEXT,
refresh_token TEXT
) /*$wgDBTableOptions*/;
CREATE INDEX /*i*/isekai_oidc_subject ON /*_*/isekai_oidc(oidc_subject);

@ -0,0 +1,7 @@
CREATE TABLE isekai_oidc (
oidc_user INTEGER NOT NULL PRIMARY KEY,
oidc_subject TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT
);
CREATE INDEX isekai_oidc_subject ON isekai_oidc oidc_subject;
Loading…
Cancel
Save