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.

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