You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

602 lines
18 KiB
PHP

<?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 MediaWiki\User\User;
use MediaWiki\Title\Title;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Parser\Sanitizer;
use MediaWiki\Context\RequestContext;
use MediaWiki\Message\Message;
use StatusValue;
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 ) {
$services = MediaWikiServices::getInstance();
$config = $services->getMainConfig()->get('IsekaiOIDC');
$oidc = self::getOpenIDConnectClient();
$requestCtx = RequestContext::getMain();
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;
}
$authManager = MediaWikiServices::getInstance()->getAuthManager();
$request = $requestCtx->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 {
$message = Message::newFromSpecifier('isekaioidc-login-failed');
return AuthenticationResponse::newFail($message);
}
}
/**
* 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() {
$services = MediaWikiServices::getInstance();
$config = $services->getMainConfig()->get('IsekaiOIDC');
if (!is_array($config)) {
wfDebugLog( self::LOG_TAG, 'wgIsekaiOIDC not set' .
PHP_EOL );
throw new Exception('wgIsekaiOIDC not set');
}
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['force'] ) || 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 ) ) {
$scope = [ $scope ];
}
$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 = MediaWikiServices::getInstance()->getUserFactory()->newFromName($user);
}
if ($data) {
$accessToken = $data['accessToken'] ?? null;
$refreshToken = $data['refreshToken'] ?? null;
$newEmail = $data['email'] ?? null;
$newRealName = $data['realname'] ?? null;
$newPhone = $data['phone'] ?? null;
} else {
$authManager = MediaWikiServices::getInstance()->getAuthManager();
$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 = MediaWikiServices::getInstance()->getDBLoadBalancer()->getMaintenanceConnectionRef( DB_PRIMARY );
$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 ) {
$authManager = MediaWikiServices::getInstance()->getAuthManager();
if ( $this->subject === null ) {
$this->subject = $authManager->getAuthenticationSessionData(
self::OIDC_SUBJECT_SESSION_KEY );
$authManager->removeAuthenticationSessionData(
self::OIDC_SUBJECT_SESSION_KEY );
}
$dbw = MediaWikiServices::getInstance()->getDBLoadBalancer()->getMaintenanceConnectionRef( DB_PRIMARY );
$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 = MediaWikiServices::getInstance()->getDBLoadBalancer()->getMaintenanceConnectionRef( 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 = MediaWikiServices::getInstance()->getDBLoadBalancer()->getMaintenanceConnectionRef( 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 ];
}
}
public static function findOidcSubjectsByUserIds( array $userIds ) {
$dbr = MediaWikiServices::getInstance()->getDBLoadBalancer()->getMaintenanceConnectionRef( DB_REPLICA );
$rows = $dbr->select(
[
self::OIDC_TABLE
],
[
'oidc_user',
'oidc_subject'
],
[
'oidc_user' => $userIds
],
__METHOD__
);
$subjects = [];
foreach ( $rows as $row ) {
$subjects[$row->oidc_user] = $row->oidc_subject;
}
return $subjects;
}
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 = MediaWikiServices::getInstance()->getDBLoadBalancer()->getMaintenanceConnectionRef( 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 = MediaWikiServices::getInstance()->getDBLoadBalancer()->getMaintenanceConnectionRef( 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';
}
$userIdentityLookup = MediaWikiServices::getInstance()->getUserIdentityLookup();
$userIdentity = $userIdentityLookup->getUserIdentityByName( $preferred_username );
if ( !$userIdentity || !$userIdentity->isRegistered() ) {
return $preferred_username;
}
$count = 1;
while ( true ) {
$userIdentity = $userIdentityLookup->getUserIdentityByName( $preferred_username . $count );
if ( !$userIdentity || !$userIdentity->isRegistered() ) {
break;
}
$count ++;
}
return $preferred_username . $count;
}
protected static function punycodeEnc( $str ){
$punycode = new PunyCode();
return $punycode->encode( $str );
}
}