commit 126c1a13376dd0a42fd147fd3c69c6190f59f553 Author: Lex Lim Date: Sun Jun 5 13:36:47 2022 +0800 Initialize Project diff --git a/IsekaiOIDC.alias.php b/IsekaiOIDC.alias.php new file mode 100644 index 0000000..4812422 --- /dev/null +++ b/IsekaiOIDC.alias.php @@ -0,0 +1,6 @@ + ['IsekaiOIDCCallback'], +]; \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a55190d --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/extension.json b/extension.json new file mode 100644 index 0000000..a7fafaa --- /dev/null +++ b/extension.json @@ -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 +} diff --git a/i18n/en.json b/i18n/en.json new file mode 100644 index 0000000..811872b --- /dev/null +++ b/i18n/en.json @@ -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" +} \ No newline at end of file diff --git a/i18n/zh-hans.json b/i18n/zh-hans.json new file mode 100644 index 0000000..c14bb55 --- /dev/null +++ b/i18n/zh-hans.json @@ -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": "前往用户中心" +} \ No newline at end of file diff --git a/includes/ApiOidcWebhook.php b/includes/ApiOidcWebhook.php new file mode 100644 index 0000000..d1d7e60 --- /dev/null +++ b/includes/ApiOidcWebhook.php @@ -0,0 +1,96 @@ +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', + ] + ]; + } +} diff --git a/includes/IsekaiOIDCAuth.php b/includes/IsekaiOIDCAuth.php new file mode 100644 index 0000000..a6407bf --- /dev/null +++ b/includes/IsekaiOIDCAuth.php @@ -0,0 +1,583 @@ +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); + } +} diff --git a/includes/IsekaiOIDCAuthBeginAuthenticationRequest.php b/includes/IsekaiOIDCAuthBeginAuthenticationRequest.php new file mode 100644 index 0000000..92ae174 --- /dev/null +++ b/includes/IsekaiOIDCAuthBeginAuthenticationRequest.php @@ -0,0 +1,27 @@ +action !== AuthManager::ACTION_LOGIN ) { + return []; + } + return parent::getFieldInfo(); + } +} diff --git a/includes/IsekaiOIDCAuthHooks.php b/includes/IsekaiOIDCAuthHooks.php new file mode 100644 index 0000000..ff40fa2 --- /dev/null +++ b/includes/IsekaiOIDCAuthHooks.php @@ -0,0 +1,170 @@ + $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, + ]) . + '' . + 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; + } +} diff --git a/includes/PunyCode.php b/includes/PunyCode.php new file mode 100644 index 0000000..1dd8929 --- /dev/null +++ b/includes/PunyCode.php @@ -0,0 +1,355 @@ + 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); + } + } +} diff --git a/includes/SpecialIsekaiOIDCCallback.php b/includes/SpecialIsekaiOIDCCallback.php new file mode 100644 index 0000000..fc9979a --- /dev/null +++ b/includes/SpecialIsekaiOIDCCallback.php @@ -0,0 +1,131 @@ + '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, + ] ); + } +} diff --git a/lib/openid-connect-php/OpenIDConnectClient.php b/lib/openid-connect-php/OpenIDConnectClient.php new file mode 100644 index 0000000..bb42b5a --- /dev/null +++ b/lib/openid-connect-php/OpenIDConnectClient.php @@ -0,0 +1,1515 @@ + + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + */ + +namespace Jumbojett; + +use stdClass; + +/** + * Use session to manage a nonce + */ +if (!isset($_SESSION)) { + @session_start(); +} + +/** + * + * JWT signature verification support by Jonathan Reed + * Licensed under the same license as the rest of this file. + * + * phpseclib is required to validate the signatures of some tokens. + * It can be downloaded from: http://phpseclib.sourceforge.net/ + */ + +if (!class_exists('\phpseclib\Crypt\RSA') && !class_exists('Crypt_RSA')) { + user_error('Unable to find phpseclib Crypt/RSA.php. Ensure phpseclib is installed and in include_path before you include this file'); +} + +/** + * A wrapper around base64_decode which decodes Base64URL-encoded data, + * which is not the same alphabet as base64. + */ +function base64url_decode($base64url) +{ + return base64_decode(b64url2b64($base64url)); +} + +/** + * Per RFC4648, "base64 encoding with URL-safe and filename-safe + * alphabet". This just replaces characters 62 and 63. None of the + * reference implementations seem to restore the padding if necessary, + * but we'll do it anyway. + * + */ +function b64url2b64($base64url) +{ + // "Shouldn't" be necessary, but why not + $padding = strlen($base64url) % 4; + if ($padding > 0) { + $base64url .= str_repeat("=", 4 - $padding); + } + return strtr($base64url, '-_', '+/'); +} + + +/** + * OpenIDConnect Exception Class + */ +class OpenIDConnectClientException extends \Exception +{ +} + +/** + * Require the CURL and JSON PHP extentions to be installed + */ +if (!function_exists('curl_init')) { + throw new OpenIDConnectClientException('OpenIDConnect needs the CURL PHP extension.'); +} +if (!function_exists('json_decode')) { + throw new OpenIDConnectClientException('OpenIDConnect needs the JSON PHP extension.'); +} + +/** + * + * Please note this class stores nonces by default in $_SESSION['openid_connect_nonce'] + * + */ +class OpenIDConnectClient +{ + + /** + * @var string arbitrary id value + */ + private $clientID; + + /* + * @var string arbitrary name value + */ + private $clientName; + + /** + * @var string arbitrary secret value + */ + private $clientSecret; + + /** + * @var array holds the provider configuration + */ + private $providerConfig = array(); + + /** + * @var string http proxy if necessary + */ + private $httpProxy; + + /** + * @var string full system path to the SSL certificate + */ + private $certPath; + + /** + * @var bool Verify SSL peer on transactions + */ + private $verifyPeer = true; + + /** + * @var bool Verify peer hostname on transactions + */ + private $verifyHost = true; + + /** + * @var string if we aquire an access token it will be stored here + */ + private $accessToken; + + /** + * @var string if we aquire a refresh token it will be stored here + */ + private $refreshToken; + + /** + * @var string if we acquire an id token it will be stored here + */ + private $idToken; + + /** + * @var string stores the token response + */ + private $tokenResponse; + + /** + * @var array holds scopes + */ + private $scopes = array(); + + /** + * @var int|null Response code from the server + */ + private $responseCode = null; + + /** + * @var array holds response types + */ + private $responseTypes = array(); + + /** + * @var array holds a cache of info returned from the user info endpoint + */ + private $userInfo = array(); + + /** + * @var array holds authentication parameters + */ + private $authParams = array(); + + /** + * @var mixed holds well-known openid server properties + */ + private $wellKnown = false; + + /** + * @var int timeout (seconds) + */ + protected $timeOut = 60; + + /** + * @var stdClass holds response types + */ + private $additionalJwks; + + /** + * @var stdClass holds verified jwt claims + */ + private $verifiedClaims; + + /** + * @var bool Allow OAuth 2 implicit flow; see http://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth + */ + private $allowImplicitFlow = false; + + /** + * @param $provider_url string optional + * + * @param $client_id string optional + * @param $client_secret string optional + * + */ + public function __construct($provider_url = null, $client_id = null, $client_secret = null) + { + $this->setProviderURL($provider_url); + $this->clientID = $client_id; + $this->clientSecret = $client_secret; + + $this->additionalJwks = new stdClass(); + $this->verifiedClaims = new stdClass(); + } + + /** + * @param $provider_url + */ + public function setProviderURL($provider_url) + { + $this->providerConfig['issuer'] = $provider_url; + } + + /** + * @param $response_types + */ + public function setResponseTypes($response_types) + { + $this->responseTypes = array_merge($this->responseTypes, (array)$response_types); + } + + /** + * @return bool + * @throws OpenIDConnectClientException + */ + public function authenticate() + { + + // Do a preemptive check to see if the provider has thrown an error from a previous redirect + if (isset($_REQUEST['error'])) { + $desc = isset($_REQUEST['error_description']) ? " Description: " . $_REQUEST['error_description'] : ""; + throw new OpenIDConnectClientException("Error: " . $_REQUEST['error'] . $desc); + } + + // If we have an authorization code then proceed to request a token + if (isset($_REQUEST["code"])) { + + $code = $_REQUEST["code"]; + $token_json = $this->requestTokens($code); + + // Throw an error if the server returns one + if (isset($token_json->error)) { + if (isset($token_json->error_description)) { + throw new OpenIDConnectClientException($token_json->error_description); + } + throw new OpenIDConnectClientException('Got response: ' . $token_json->error); + } + + // Do an OpenID Connect session check + if ($_REQUEST['state'] != $this->getState()) { + throw new OpenIDConnectClientException("Unable to determine state"); + } + + // Cleanup state + $this->unsetState(); + + if (!property_exists($token_json, 'id_token')) { + throw new OpenIDConnectClientException("User did not authorize openid scope."); + } + + $claims = $this->decodeJWT($token_json->id_token, 1); + + // Verify the signature + if ($this->canVerifySignatures()) { + if (!$this->getProviderConfigValue('jwks_uri')) { + throw new OpenIDConnectClientException("Unable to verify signature due to no jwks_uri being defined"); + } + if (!$this->verifyJWTsignature($token_json->id_token)) { + throw new OpenIDConnectClientException("Unable to verify signature"); + } + } else { + user_error("Warning: JWT signature verification unavailable."); + } + + // If this is a valid claim + if ($this->verifyJWTclaims($claims, $token_json->access_token)) { + + // Clean up the session a little + $this->unsetNonce(); + + // Save the full response + $this->tokenResponse = $token_json; + + // Save the id token + $this->idToken = $token_json->id_token; + + // Save the access token + $this->accessToken = $token_json->access_token; + + // Save the verified claims + $this->verifiedClaims = $claims; + + // Save the refresh token, if we got one + if (isset($token_json->refresh_token)) $this->refreshToken = $token_json->refresh_token; + + // Success! + return true; + } else { + throw new OpenIDConnectClientException("Unable to verify JWT claims"); + } + } elseif ($this->allowImplicitFlow && isset($_REQUEST["id_token"])) { + // if we have no code but an id_token use that + $id_token = $_REQUEST["id_token"]; + + $accessToken = null; + if (isset($_REQUEST["access_token"])) { + $accessToken = $_REQUEST["access_token"]; + } + + // Do an OpenID Connect session check + if ($_REQUEST['state'] != $this->getState()) { + throw new OpenIDConnectClientException("Unable to determine state"); + } + + // Cleanup state + $this->unsetState(); + + $claims = $this->decodeJWT($id_token, 1); + + // Verify the signature + if ($this->canVerifySignatures()) { + if (!$this->getProviderConfigValue('jwks_uri')) { + throw new OpenIDConnectClientException("Unable to verify signature due to no jwks_uri being defined"); + } + if (!$this->verifyJWTsignature($id_token)) { + throw new OpenIDConnectClientException("Unable to verify signature"); + } + } else { + user_error("Warning: JWT signature verification unavailable."); + } + + // If this is a valid claim + if ($this->verifyJWTclaims($claims, $accessToken)) { + + // Clean up the session a little + $this->unsetNonce(); + + // Save the id token + $this->idToken = $id_token; + + // Save the verified claims + $this->verifiedClaims = $claims; + + // Save the access token + if ($accessToken) $this->accessToken = $accessToken; + + // Save the refresh token, if we got one + if (isset($_REQUEST['refresh_token'])) $this->refreshToken = $_REQUEST['refresh_token']; + + // Success! + return true; + } else { + throw new OpenIDConnectClientException("Unable to verify JWT claims"); + } + } else { + return false; + } + } + + /** + * It calls the end-session endpoint of the OpenID Connect provider to notify the OpenID + * Connect provider that the end-user has logged out of the relying party site + * (the client application). + * + * @param string $accessToken ID token (obtained at login) + * @param string $redirect URL to which the RP is requesting that the End-User's User Agent + * be redirected after a logout has been performed. The value MUST have been previously + * registered with the OP. Value can be null. + * + */ + public function signOut($accessToken, $redirect) + { + $signout_endpoint = $this->getProviderConfigValue("end_session_endpoint"); + + $signout_params = null; + if ($redirect == null) { + $signout_params = array('id_token_hint' => $accessToken); + } else { + $signout_params = array( + 'id_token_hint' => $accessToken, + 'post_logout_redirect_uri' => $redirect + ); + } + + $signout_endpoint .= (strpos($signout_endpoint, '?') === false ? '?' : '&') . http_build_query($signout_params); + $this->redirect($signout_endpoint); + } + + /** + * @param $scope - example: openid, given_name, etc... + */ + public function addScope($scope) + { + $this->scopes = array_merge($this->scopes, (array)$scope); + } + + /** + * @param $param - example: prompt=login + */ + public function addAuthParam($param) + { + $this->authParams = array_merge($this->authParams, (array)$param); + } + + /** + * @param $jwk object - example: (object) array('kid' => ..., 'nbf' => ..., 'use' => 'sig', 'kty' => "RSA", 'e' => "", 'n' => "") + */ + protected function addAdditionalJwk($jwk) + { + $this->additionalJwks[] = $jwk; + } + + /** + * Get's anything that we need configuration wise including endpoints, and other values + * + * @param $param + * @param string $default optional + * @throws OpenIDConnectClientException + * @return mixed + * + */ + private function getProviderConfigValue($param, $default = null) + { + + // If the configuration value is not available, attempt to fetch it from a well known config endpoint + // This is also known as auto "discovery" + if (!isset($this->providerConfig[$param])) { + $this->providerConfig[$param] = $this->getWellKnownConfigValue($param, $default); + } + + return $this->providerConfig[$param]; + } + + /** + * Get's anything that we need configuration wise including endpoints, and other values + * + * @param $param + * @param string $default optional + * @throws OpenIDConnectClientException + * @return string + * + */ + private function getWellKnownConfigValue($param, $default = null) + { + + // If the configuration value is not available, attempt to fetch it from a well known config endpoint + // This is also known as auto "discovery" + if (!$this->wellKnown) { + $well_known_config_url = rtrim($this->getProviderURL(), "/") . "/.well-known/openid-configuration"; + $this->wellKnown = json_decode($this->fetchURL($well_known_config_url)); + } + + $value = false; + if (isset($this->wellKnown->{$param})) { + $value = $this->wellKnown->{$param}; + } + + if ($value) { + return $value; + } elseif (isset($default)) { + // Uses default value if provided + return $default; + } else { + throw new OpenIDConnectClientException("The provider {$param} could not be fetched. Make sure your provider has a well known configuration available."); + } + } + + + /** + * @param string $url Sets redirect URL for auth flow + */ + public function setRedirectURL($url) + { + if (parse_url($url, PHP_URL_HOST) !== false) { + $this->redirectURL = $url; + } + } + + /** + * Gets the URL of the current page we are on, encodes, and returns it + * + * @return string + */ + public function getRedirectURL() + { + + // If the redirect URL has been set then return it. + if (property_exists($this, 'redirectURL') && $this->redirectURL) { + return $this->redirectURL; + } + + // Other-wise return the URL of the current page + + /** + * Thank you + * http://stackoverflow.com/questions/189113/how-do-i-get-current-page-full-url-in-php-on-a-windows-iis-server + */ + + /* + * Compatibility with multiple host headers. + * The problem with SSL over port 80 is resolved and non-SSL over port 443. + * Support of 'ProxyReverse' configurations. + */ + + if (isset($_SERVER["HTTP_UPGRADE_INSECURE_REQUESTS"]) && ($_SERVER['HTTP_UPGRADE_INSECURE_REQUESTS'] == 1)) { + $protocol = 'https'; + } else { + $protocol = @$_SERVER['HTTP_X_FORWARDED_PROTO'] + ?: @$_SERVER['REQUEST_SCHEME'] + ?: ((isset($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] == "on") ? "https" : "http"); + } + + $port = @intval($_SERVER['HTTP_X_FORWARDED_PORT']) + ?: @intval($_SERVER["SERVER_PORT"]) + ?: (($protocol === 'https') ? 443 : 80); + + $host = @explode(":", $_SERVER['HTTP_HOST'])[0] + ?: @$_SERVER['SERVER_NAME'] + ?: @$_SERVER['SERVER_ADDR']; + + $port = (443 == $port) || (80 == $port) ? '' : ':' . $port; + + return sprintf('%s://%s%s/%s', $protocol, $host, $port, @trim(reset(explode("?", $_SERVER['REQUEST_URI'])), '/')); + } + + /** + * Used for arbitrary value generation for nonces and state + * + * @return string + */ + protected function generateRandString() + { + return md5(uniqid(rand(), TRUE)); + } + + /** + * Start Here + * @return void + */ + public function getAuthorizationUrl() + { + $auth_endpoint = $this->getProviderConfigValue("authorization_endpoint"); + $response_type = "code"; + + // Generate and store a nonce in the session + // The nonce is an arbitrary value + $nonce = $this->setNonce($this->generateRandString()); + + // State essentially acts as a session key for OIDC + $state = $this->setState($this->generateRandString()); + + $auth_params = array_merge($this->authParams, array( + 'response_type' => $response_type, + 'redirect_uri' => $this->getRedirectURL(), + 'client_id' => $this->clientID, + 'nonce' => $nonce, + 'state' => $state, + 'scope' => 'openid' + )); + + // If the client has been registered with additional scopes + if (sizeof($this->scopes) > 0) { + $auth_params = array_merge($auth_params, array('scope' => implode(' ', $this->scopes))); + } + + // If the client has been registered with additional response types + if (sizeof($this->responseTypes) > 0) { + $auth_params = array_merge($auth_params, array('response_type' => implode(' ', $this->responseTypes))); + } + + $auth_endpoint .= (strpos($auth_endpoint, '?') === false ? '?' : '&') . http_build_query($auth_params); + + session_commit(); + return $auth_endpoint; + } + + /** + * Requests a client credentials token + * + */ + public function requestClientCredentialsToken() + { + $token_endpoint = $this->getProviderConfigValue("token_endpoint"); + + $headers = []; + + $grant_type = "client_credentials"; + + $post_data = array( + 'grant_type' => $grant_type, + 'client_id' => $this->clientID, + 'client_secret' => $this->clientSecret, + 'scope' => implode(' ', $this->scopes) + ); + + // Convert token params to string format + $post_params = http_build_query($post_data); + + return json_decode($this->fetchURL($token_endpoint, $post_params, $headers)); + } + + + /** + * Requests a resource owner token + * (Defined in https://tools.ietf.org/html/rfc6749#section-4.3) + * + * @param $bClientAuth boolean Indicates that the Client ID and Secret be used for client authentication + */ + public function requestResourceOwnerToken($bClientAuth = FALSE) + { + $token_endpoint = $this->getProviderConfigValue("token_endpoint"); + + $headers = []; + + $grant_type = "password"; + + $post_data = array( + 'grant_type' => $grant_type, + 'username' => $this->authParams['username'], + 'password' => $this->authParams['password'], + 'scope' => implode(' ', $this->scopes) + ); + + //For client authentication include the client values + if ($bClientAuth) { + $post_data['client_id'] = $this->clientID; + $post_data['client_secret'] = $this->clientSecret; + } + + // Convert token params to string format + $post_params = http_build_query($post_data); + + return json_decode($this->fetchURL($token_endpoint, $post_params, $headers)); + } + + + + + /** + * Requests ID and Access tokens + * + * @param $code + * @return mixed + */ + private function requestTokens($code) + { + $token_endpoint = $this->getProviderConfigValue("token_endpoint"); + $token_endpoint_auth_methods_supported = $this->getProviderConfigValue("token_endpoint_auth_methods_supported", ['client_secret_basic']); + + $headers = []; + + $grant_type = "authorization_code"; + + $token_params = array( + 'grant_type' => $grant_type, + 'code' => $code, + 'redirect_uri' => $this->getRedirectURL(), + 'client_id' => $this->clientID, + 'client_secret' => $this->clientSecret + ); + + # Consider Basic authentication if provider config is set this way + if (in_array('client_secret_basic', $token_endpoint_auth_methods_supported)) { + $headers = ['Authorization: Basic ' . base64_encode($this->clientID . ':' . $this->clientSecret)]; + unset($token_params['client_secret']); + } + + // Convert token params to string format + $token_params = http_build_query($token_params); + + return json_decode($this->fetchURL($token_endpoint, $token_params, $headers)); + } + + /** + * Requests Access token with refresh token + * + * @param $code + * @return mixed + */ + public function refreshToken($refresh_token) + { + $token_endpoint = $this->getProviderConfigValue("token_endpoint"); + + $grant_type = "refresh_token"; + + $token_params = array( + 'grant_type' => $grant_type, + 'refresh_token' => $refresh_token, + 'client_id' => $this->clientID, + 'client_secret' => $this->clientSecret, + ); + + // Convert token params to string format + $token_params = http_build_query($token_params); + + $json = json_decode($this->fetchURL($token_endpoint, $token_params)); + $this->refreshToken = $json->refresh_token; + $this->accessToken = $json->access_token; + + return $json; + } + + /** + * @param array $keys + * @param stdClass $header + * @throws OpenIDConnectClientException + * @return object + */ + private function get_key_for_header($keys, $header) + { + foreach ($keys as $key) { + if ($key->kty == 'RSA') { + if (!isset($header->kid) || $key->kid == $header->kid) { + return $key; + } + } else { + if ($key->alg == $header->alg && $key->kid == $header->kid) { + return $key; + } + } + } + if ($this->additionalJwks) { + foreach ($this->additionalJwks as $key) { + if ($key->kty == 'RSA') { + if (!isset($header->kid) || $key->kid == $header->kid) { + return $key; + } + } else { + if ($key->alg == $header->alg && $key->kid == $header->kid) { + return $key; + } + } + } + } + if (isset($header->kid)) { + throw new OpenIDConnectClientException('Unable to find a key for (algorithm, kid):' . $header->alg . ', ' . $header->kid . ')'); + } else { + throw new OpenIDConnectClientException('Unable to find a key for RSA'); + } + } + + + + /** + * @param string $hashtype + * @param object $key + * @throws OpenIDConnectClientException + * @return bool + */ + private function verifyRSAJWTsignature($hashtype, $key, $payload, $signature) + { + if (!class_exists('\phpseclib\Crypt\RSA') && !class_exists('Crypt_RSA')) { + throw new OpenIDConnectClientException('Crypt_RSA support unavailable.'); + } + if (!(property_exists($key, 'n') and property_exists($key, 'e'))) { + throw new OpenIDConnectClientException('Malformed key object'); + } + + /* We already have base64url-encoded data, so re-encode it as + regular base64 and use the XML key format for simplicity. + */ + $public_key_xml = "\r\n" . + " " . b64url2b64($key->n) . "\r\n" . + " " . b64url2b64($key->e) . "\r\n" . + ""; + if (class_exists('Crypt_RSA')) { + $rsa = new Crypt_RSA(); + $rsa->setHash($hashtype); + $rsa->loadKey($public_key_xml, Crypt_RSA::PUBLIC_FORMAT_XML); + $rsa->signatureMode = Crypt_RSA::SIGNATURE_PKCS1; + } else { + $rsa = new \phpseclib\Crypt\RSA(); + $rsa->setHash($hashtype); + $rsa->loadKey($public_key_xml, \phpseclib\Crypt\RSA::PUBLIC_FORMAT_XML); + $rsa->signatureMode = \phpseclib\Crypt\RSA::SIGNATURE_PKCS1; + } + return $rsa->verify($payload, $signature); + } + + /** + * @param string $hashtype + * @param string $key + * @throws OpenIDConnectClientException + * @return bool + */ + private function verifyHMACJWTsignature($hashtype, $key, $payload, $signature) + { + if (!function_exists('hash_hmac')) { + throw new OpenIDConnectClientException('hash_hmac support unavailable.'); + } + + $expected = hash_hmac($hashtype, $payload, $key, true); + + if (function_exists('hash_equals')) { + return hash_equals($signature, $expected); + } else { + return self::hashEquals($signature, $expected); + } + } + + /** + * @param $jwt string encoded JWT + * @throws OpenIDConnectClientException + * @return bool + */ + private function verifyJWTsignature($jwt) + { + $parts = explode(".", $jwt); + $signature = base64url_decode(array_pop($parts)); + $header = json_decode(base64url_decode($parts[0])); + $payload = implode(".", $parts); + $jwks = json_decode($this->fetchURL($this->getProviderConfigValue('jwks_uri'))); + if ($jwks === NULL) { + throw new OpenIDConnectClientException('Error decoding JSON from jwks_uri'); + } + $verified = false; + switch ($header->alg) { + case 'RS256': + case 'RS384': + case 'RS512': + $hashtype = 'sha' . substr($header->alg, 2); + + $verified = $this->verifyRSAJWTsignature( + $hashtype, + $this->get_key_for_header($jwks->keys, $header), + $payload, + $signature + ); + break; + case 'HS256': + case 'HS512': + case 'HS384': + $hashtype = 'SHA' . substr($header->alg, 2); + $verified = $this->verifyHMACJWTsignature($hashtype, $this->getClientSecret(), $payload, $signature); + break; + default: + throw new OpenIDConnectClientException('No support for signature type: ' . $header->alg); + } + return $verified; + } + + /** + * @param object $claims + * @return bool + */ + private function verifyJWTclaims($claims, $accessToken = null) + { + if (isset($claims->at_hash) && isset($accessToken)) { + if (isset($this->getAccessTokenHeader()->alg) && $this->getAccessTokenHeader()->alg != 'none') { + $bit = substr($this->getAccessTokenHeader()->alg, 2, 3); + } else { + // TODO: Error case. throw exception??? + $bit = '256'; + } + $len = ((int)$bit) / 16; + $expecte_at_hash = $this->urlEncode(substr(hash('sha' . $bit, $accessToken, true), 0, $len)); + } + return (($claims->iss == $this->getProviderURL() || $claims->iss == $this->getWellKnownIssuer() || $claims->iss == $this->getWellKnownIssuer(true)) + && (($claims->aud == $this->clientID) || (in_array($this->clientID, $claims->aud))) + && ($claims->nonce == $this->getNonce()) + && (!isset($claims->exp) || $claims->exp >= time()) + && (!isset($claims->nbf) || $claims->nbf <= time()) + && (!isset($claims->at_hash) || $claims->at_hash == $expecte_at_hash)); + } + + /** + * @param string $str + * @return string + */ + protected function urlEncode($str) + { + $enc = base64_encode($str); + $enc = rtrim($enc, "="); + $enc = strtr($enc, "+/", "-_"); + return $enc; + } + + /** + * @param $jwt string encoded JWT + * @param int $section the section we would like to decode + * @return object + */ + private function decodeJWT($jwt, $section = 0) + { + + $parts = explode(".", $jwt); + return json_decode(base64url_decode($parts[$section])); + } + + /** + * + * @param $attribute string optional + * + * Attribute Type Description + * user_id string REQUIRED Identifier for the End-User at the Issuer. + * name string End-User's full name in displayable form including all name parts, ordered according to End-User's locale and preferences. + * given_name string Given name or first name of the End-User. + * family_name string Surname or last name of the End-User. + * middle_name string Middle name of the End-User. + * nickname string Casual name of the End-User that may or may not be the same as the given_name. For instance, a nickname value of Mike might be returned alongside a given_name value of Michael. + * profile string URL of End-User's profile page. + * picture string URL of the End-User's profile picture. + * website string URL of End-User's web page or blog. + * email string The End-User's preferred e-mail address. + * verified boolean True if the End-User's e-mail address has been verified; otherwise false. + * gender string The End-User's gender: Values defined by this specification are female and male. Other values MAY be used when neither of the defined values are applicable. + * birthday string The End-User's birthday, represented as a date string in MM/DD/YYYY format. The year MAY be 0000, indicating that it is omitted. + * zoneinfo string String from zoneinfo [zoneinfo] time zone database. For example, Europe/Paris or America/Los_Angeles. + * locale string The End-User's locale, represented as a BCP47 [RFC5646] language tag. This is typically an ISO 639-1 Alpha-2 [ISO639‑1] language code in lowercase and an ISO 3166-1 Alpha-2 [ISO3166‑1] country code in uppercase, separated by a dash. For example, en-US or fr-CA. As a compatibility note, some implementations have used an underscore as the separator rather than a dash, for example, en_US; Implementations MAY choose to accept this locale syntax as well. + * phone_number string The End-User's preferred telephone number. E.164 [E.164] is RECOMMENDED as the format of this Claim. For example, +1 (425) 555-1212 or +56 (2) 687 2400. + * address JSON object The End-User's preferred address. The value of the address member is a JSON [RFC4627] structure containing some or all of the members defined in Section 2.4.2.1. + * updated_time string Time the End-User's information was last updated, represented as a RFC 3339 [RFC3339] datetime. For example, 2011-01-03T23:58:42+0000. + * + * @return mixed + * + */ + public function requestUserInfo($attribute = null) + { + if (!$this->userInfo) { + $user_info_endpoint = $this->getProviderConfigValue("userinfo_endpoint"); + $schema = 'openid'; + + $user_info_endpoint .= "?schema=" . $schema; + + //The accessToken has to be send in the Authorization header, so we create a new array with only this header. + $headers = array("Authorization: Bearer {$this->accessToken}"); + + $user_json = json_decode($this->fetchURL($user_info_endpoint, null, $headers)); + + $this->userInfo = $user_json; + } + + if ($attribute === null) { + return $this->userInfo; + } else if (isset($this->userInfo->$attribute)) { + return $this->userInfo->$attribute; + } else { + return null; + } + } + + /** + * + * @param $attribute string optional + * + * Attribute Type Description + * exp int Expires at + * nbf int Not before + * ver string Version + * iss string Issuer + * sub string Subject + * aud string Audience + * nonce string nonce + * iat int Issued At + * auth_time int Authenatication time + * oid string Object id + * + * @return mixed + * + */ + public function getVerifiedClaims($attribute = null) + { + + if ($attribute === null) { + return $this->verifiedClaims; + } else if (isset($this->verifiedClaims->$attribute)) { + return $this->verifiedClaims->$attribute; + } else { + return null; + } + } + + /** + * @param $url + * @param null $post_body string If this is set the post type will be POST + * @param array() $headers Extra headers to be send with the request. Format as 'NameHeader: ValueHeader' + * @throws OpenIDConnectClientException + * @return mixed + */ + protected function fetchURL($url, $post_body = null, $headers = array()) + { + + + // OK cool - then let's create a new cURL resource handle + $ch = curl_init(); + + // Determine whether this is a GET or POST + if ($post_body != null) { + // curl_setopt($ch, CURLOPT_POST, 1); + // Alows to keep the POST method even after redirect + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); + curl_setopt($ch, CURLOPT_POSTFIELDS, $post_body); + + // Default content type is form encoded + $content_type = 'application/x-www-form-urlencoded'; + + // Determine if this is a JSON payload and add the appropriate content type + if (is_object(json_decode($post_body))) { + $content_type = 'application/json'; + } + + // Add POST-specific headers + $headers[] = "Content-Type: {$content_type}"; + $headers[] = 'Content-Length: ' . strlen($post_body); + } + + // If we set some heaers include them + if (count($headers) > 0) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + } + + // Set URL to download + curl_setopt($ch, CURLOPT_URL, $url); + + if (isset($this->httpProxy)) { + curl_setopt($ch, CURLOPT_PROXY, $this->httpProxy); + } + + // Include header in result? (0 = yes, 1 = no) + curl_setopt($ch, CURLOPT_HEADER, 0); + + // Allows to follow redirect + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + + /** + * Set cert + * Otherwise ignore SSL peer verification + */ + if (isset($this->certPath)) { + curl_setopt($ch, CURLOPT_CAINFO, $this->certPath); + } + + if ($this->verifyHost) { + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + } else { + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); + } + + if ($this->verifyPeer) { + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + } else { + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + } + + // Should cURL return or print out the data? (true = return, false = print) + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + // Timeout in seconds + curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeOut); + + // Download the given URL, and return output + $output = curl_exec($ch); + + // HTTP Response code from server may be required from subclass + $info = curl_getinfo($ch); + $this->responseCode = $info['http_code']; + + if ($output === false) { + throw new OpenIDConnectClientException('Curl error: ' . curl_error($ch)); + } + + // Close the cURL resource, and free system resources + curl_close($ch); + + return $output; + } + + /** + * @return string + * @throws OpenIDConnectClientException + */ + public function getWellKnownIssuer($appendSlash = false) + { + + return $this->getWellKnownConfigValue('issuer') . ($appendSlash ? '/' : ''); + } + + /** + * @return string + * @throws OpenIDConnectClientException + */ + public function getProviderURL() + { + + if (!isset($this->providerConfig['issuer'])) { + throw new OpenIDConnectClientException("The provider URL has not been set"); + } else { + return $this->providerConfig['issuer']; + } + } + + /** + * @param $url + */ + public function redirect($url) + { + header('Location: ' . $url); + exit; + } + + /** + * @param $httpProxy + */ + public function setHttpProxy($httpProxy) + { + $this->httpProxy = $httpProxy; + } + + /** + * @param $certPath + */ + public function setCertPath($certPath) + { + $this->certPath = $certPath; + } + + /** + * @return string|null + */ + public function getCertPath() + { + return $this->certPath; + } + + /** + * @param bool $verifyPeer + */ + public function setVerifyPeer($verifyPeer) + { + $this->verifyPeer = $verifyPeer; + } + + /** + * @param bool $verifyHost + */ + public function setVerifyHost($verifyHost) + { + $this->verifyHost = $verifyHost; + } + + /** + * @return bool + */ + public function getVerifyHost() + { + return $this->verifyHost; + } + + /** + * @return bool + */ + public function getVerifyPeer() + { + return $this->verifyPeer; + } + + /** + * @param bool $allowImplicitFlow + */ + public function setAllowImplicitFlow($allowImplicitFlow) + { + $this->allowImplicitFlow = $allowImplicitFlow; + } + + /** + * @return bool + */ + public function getAllowImplicitFlow() + { + return $this->allowImplicitFlow; + } + + /** + * + * Use this to alter a provider's endpoints and other attributes + * + * @param $array + * simple key => value + */ + public function providerConfigParam($array) + { + $this->providerConfig = array_merge($this->providerConfig, $array); + } + + /** + * @param $clientSecret + */ + public function setClientSecret($clientSecret) + { + $this->clientSecret = $clientSecret; + } + + /** + * @param $clientID + */ + public function setClientID($clientID) + { + $this->clientID = $clientID; + } + + + /** + * Dynamic registration + * + * @throws OpenIDConnectClientException + */ + public function register() + { + + $registration_endpoint = $this->getProviderConfigValue('registration_endpoint'); + + $send_object = (object)array( + 'redirect_uris' => array($this->getRedirectURL()), + 'client_name' => $this->getClientName() + ); + + $response = $this->fetchURL($registration_endpoint, json_encode($send_object)); + + $json_response = json_decode($response); + + // Throw some errors if we encounter them + if ($json_response === false) { + throw new OpenIDConnectClientException("Error registering: JSON response received from the server was invalid."); + } elseif (isset($json_response->{'error_description'})) { + throw new OpenIDConnectClientException($json_response->{'error_description'}); + } + + $this->setClientID($json_response->{'client_id'}); + + // The OpenID Connect Dynamic registration protocol makes the client secret optional + // and provides a registration access token and URI endpoint if it is not present + if (isset($json_response->{'client_secret'})) { + $this->setClientSecret($json_response->{'client_secret'}); + } else { + throw new OpenIDConnectClientException("Error registering: + Please contact the OpenID Connect provider and obtain a Client ID and Secret directly from them"); + } + } + + /** + * @return mixed + */ + public function getClientName() + { + return $this->clientName; + } + + /** + * @param $clientName + */ + public function setClientName($clientName) + { + $this->clientName = $clientName; + } + + /** + * @return string + */ + public function getClientID() + { + return $this->clientID; + } + + /** + * @return string + */ + public function getClientSecret() + { + return $this->clientSecret; + } + + /** + * @return bool + */ + public function canVerifySignatures() + { + return class_exists('\phpseclib\Crypt\RSA') || class_exists('Crypt_RSA'); + } + + /** + * Set the access token. + * + * May be required for subclasses of this Client. + * + * @param mixed $accessToken + * + * @return void + */ + public function setAccessToken($accessToken) + { + $this->accessToken = $accessToken; + } + + /** + * @return string + */ + public function getAccessToken() + { + return $this->accessToken; + } + + /** + * Set the refresh token. + * + * May be required for subclasses of this Client. + * + * @param mixed $refreshToken + * + * @return void + */ + public function setRefreshToken($refreshToken) + { + $this->refreshToken = $refreshToken; + } + + /** + * @return string + */ + public function getRefreshToken() + { + return $this->refreshToken; + } + + /** + * @return string + */ + public function getIdToken() + { + return $this->idToken; + } + + /** + * @return stdClass + */ + public function getAccessTokenHeader() + { + return $this->decodeJWT($this->accessToken, 0); + } + + /** + * @return stdClass + */ + public function getAccessTokenPayload() + { + return $this->decodeJWT($this->accessToken, 1); + } + + /** + * @return stdClass + */ + public function getIdTokenHeader() + { + return $this->decodeJWT($this->idToken, 0); + } + + /** + * @return stdClass + */ + public function getIdTokenPayload() + { + return $this->decodeJWT($this->idToken, 1); + } + /** + * @return stdClass + */ + public function getTokenResponse() + { + return $this->tokenResponse; + } + + /** + * Stores nonce + * + * @param string $nonce + * @return string + */ + protected function setNonce($nonce) + { + $_SESSION['openid_connect_nonce'] = $nonce; + return $nonce; + } + + /** + * Get stored nonce + * + * @return string + */ + protected function getNonce() + { + return $_SESSION['openid_connect_nonce']; + } + + /** + * Cleanup nonce + * + * @return void + */ + protected function unsetNonce() + { + unset($_SESSION['openid_connect_nonce']); + } + + /** + * Stores $state + * + * @param string $state + * @return string + */ + protected function setState($state) + { + $_SESSION['openid_connect_state'] = $state; + return $state; + } + + /** + * Get stored state + * + * @return string + */ + protected function getState() + { + return $_SESSION['openid_connect_state']; + } + + /** + * Cleanup state + * + * @return void + */ + protected function unsetState() + { + unset($_SESSION['openid_connect_state']); + } + + /** + * Get the response code from last action/curl request. + * + * @return int + */ + public function getResponseCode() + { + return $this->responseCode; + } + + /** + * Set timeout (seconds) + * + * @param int $timeout + */ + public function setTimeout($timeout) + { + $this->timeOut = $timeout; + } + + public function getTimeout() + { + return $this->timeOut; + } + + /** + * Safely calculate length of binary string + * @param string + * @return int + */ + private static function safeLength($str) + { + if (function_exists('mb_strlen')) { + return mb_strlen($str, '8bit'); + } + return strlen($str); + } + + /** + * Where has_equals is not available, this provides a timing-attack safe string comparison + * @param $str1 + * @param $str2 + * @return bool + */ + private static function hashEquals($str1, $str2) + { + $len1 = static::safeLength($str1); + $len2 = static::safeLength($str2); + + //compare strings without any early abort... + $len = min($len1, $len2); + $status = 0; + for ($i = 0; $i < $len; $i++) { + $status |= (ord($str1[$i]) ^ ord($str2[$i])); + } + //if strings were different lengths, we fail + $status |= ($len1 ^ $len2); + return ($status === 0); + } +} diff --git a/sql/mysql/AddTable.sql b/sql/mysql/AddTable.sql new file mode 100644 index 0000000..4b5a477 --- /dev/null +++ b/sql/mysql/AddTable.sql @@ -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); diff --git a/sql/postgres/AddTable.sql b/sql/postgres/AddTable.sql new file mode 100644 index 0000000..24b1014 --- /dev/null +++ b/sql/postgres/AddTable.sql @@ -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;