<?php
namespace MediaWiki\Extension\WSOAuth\AuthenticationProvider;
use WSOAuth\AuthenticationProvider\AuthProvider;
use MediaWiki\User\UserIdentity as MWUserIdentity;
use MediaWiki\User\UserNameUtils;
use MediaWiki\MediaWikiServices; // Needed for UserFactory
class XenForoOAuthProvider extends AuthProvider {
private $authUri;
private $tokenUri;
private $userInfoUri; // Added for flexibility
private $clientId;
private $clientSecret;
private $redirectUri;
private $scopes = 'user:read'; // Default scope
private $logFilePath = '/var/www/public_html/wiki/wsoauth.log'; // Define the log file path
private function writeToLog(string $message): void {
$timestamp = date('Y-m-d H:i:s');
$logMessage = "[{$timestamp}] {$message}" . PHP_EOL;
// Ensure directory exists and is writable (add more robust checks if needed)
if (is_writable(dirname($this->logFilePath))) {
file_put_contents($this->logFilePath, $logMessage, FILE_APPEND);
} else {
error_log("WSOAuth Log Error: Directory or file not writable: " . $this->logFilePath);
}
}
public function __construct(
string $clientId,
string $clientSecret,
?string $authUri,
?string $redirectUri,
array $extensionData = []
) {
parent::__construct($clientId, $clientSecret, $authUri, $redirectUri, $extensionData);
$this->tokenUri = $extensionData['tokenUri'] ?? null;
$this->authUri = $authUri;
//forced AuthURI $this->authUri = $authUri ?? $extensionData['authUri'] ?? 'https://teststar2.astromech.net/xenforo/index.php?oauth2/authorize';
//forced TokenURI $this->tokenUri = $extensionData['tokenUri'] ?? 'https://teststar2.astromech.net/xenforo/index.php?api/oauth2/token';
// Make user info endpoint configurable
$this->userInfoUri = $extensionData['userInfoUri'] ?? 'https://teststar2.astromech.net/xenforo/api/me';
// Make scopes configurable if needed
$this->scopes = $extensionData['scopes'] ?? 'user:read';
$this->clientId = $clientId;
$this->clientSecret = $clientSecret;
$this->redirectUri = $redirectUri;
$this->writeToLog("XenForoOAuthProvider Initialized.");
$this->writeToLog("Auth URI: " . $this->authUri);
$this->writeToLog("Token URI: " . $this->tokenUri);
$this->writeToLog("User Info URI: " . $this->userInfoUri);
$this->writeToLog("Redirect URI: " . $this->redirectUri);
$this->writeToLog("Auth URI (constructor): " . ($this->authUri ?? 'not set'));
$this->writeToLog("Token URI (constructor): " . ($this->tokenUri ?? 'not set'));
// Avoid logging secrets if possible, or be very careful with log security
// $this->writeToLog("Client ID: " . $this->clientId);
if (!file_exists($this->logFilePath)) {
// Attempt to create the file; requires write permission in the directory
@touch($this->logFilePath);
}
if (!is_writable($this->logFilePath)) {
error_log("WSOAuth: Log file is not writable: " . $this->logFilePath);
}
}
// *** postRequest and getRequest remain the same, but ensure robust error checking ***
private function postRequest($url, $data) {
$this->writeToLog("POST Request to: " . $url);
$this->writeToLog("POST Data: " . http_build_query($data)); // Log data being sent
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); // Send as application/x-www-form-urlencoded
// Consider adding User-Agent
curl_setopt($ch, CURLOPT_USERAGENT, 'MediaWiki-WSOAuth-Client');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); // Ensure cert is valid
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // Ensure hostname matches cert
curl_setopt($ch, CURLOPT_TIMEOUT, 15); // Slightly longer timeout?
$response = curl_exec($ch);
$error = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$this->writeToLog("POST Response Code: " . $httpCode);
// Only log sensitive parts of the response if absolutely necessary for debugging
// $this->writeToLog("POST Raw Response: " . $response);
if ($response === false) {
$this->writeToLog("cURL Error (POST): " . $error);
error_log("WSOAuth cURL Error (POST) to " . $url . ": " . $error);
return false;
}
// Allow successful 2xx codes, not just 200, though Xenforo likely uses 200
if ($httpCode < 200 || $httpCode >= 300) {
$this->writeToLog("HTTP Error (POST): " . $httpCode . " Response: " . $response);
error_log("WSOAuth HTTP Error (POST) " . $httpCode . " from " . $url . ": " . $response);
return false;
}
return $response;
}
private function getRequest($url, $headers = []) {
$this->writeToLog("GET Request to: " . $url);
$this->writeToLog("GET Headers: " . implode(', ', $headers));
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
// Consider adding User-Agent
curl_setopt($ch, CURLOPT_USERAGENT, 'MediaWiki-WSOAuth-Client');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); // Ensure cert is valid
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // Ensure hostname matches cert
curl_setopt($ch, CURLOPT_TIMEOUT, 15); // Slightly longer timeout?
$response = curl_exec($ch);
$error = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$this->writeToLog("GET Response Code: " . $httpCode);
// $this->writeToLog("GET Raw Response: " . $response);
if ($response === false) {
$this->writeToLog("cURL Error (GET): " . $error);
error_log("WSOAuth cURL Error (GET) to " . $url . ": " . $error);
return false;
}
// Allow successful 2xx codes
if ($httpCode < 200 || $httpCode >= 300) {
$this->writeToLog("HTTP Error (GET): " . $httpCode . " Response: " . $response);
error_log("WSOAuth HTTP Error (GET) " . $httpCode . " from " . $url . ": " . $response);
return false;
}
return $response;
}
/**
* Called after the user returns from the provider with an authorization code.
* WSOAuth passes the 'code' parameter as $key and potentially state/other info as $secret.
* Signature MUST match the parent AuthProvider::getUser method.
*/
public function getUser(string $key, string $secret, &$errorMessage): ?MWUserIdentity {
// Treat the first parameter ($key) as the authorization code
$code = $key;
// The $secret parameter might contain state or other info depending on WSOAuth version/config.
// For standard OAuth2 code exchange, we might not use $secret directly here.
$this->writeToLog("getUser called. Code received (via \$key): " . $code);
$this->writeToLog("getUser called. \$secret parameter value: " . $secret);
$this->writeToLog("Attempting to exchange code for token.");
// ** CORRECTED Token Request Data **
$tokenRequestData = [
'grant_type' => 'authorization_code',
'code' => $code, // Use the code from the $key parameter
'redirect_uri' => $this->redirectUri,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret
// 'scope' => $this->scopes // Check if XenForo needs scope here
];
$rawTokenData = $this->postRequest($this->tokenUri, $tokenRequestData);
// ... rest of the getUser method remains the same ...
// ... (handle $rawTokenData, $tokenData, $userInfoResponse, $userInfo, $userId, $username, return UserIdentity or null)
if ($rawTokenData === false) {
$errorMessage = 'Failed to communicate with the token endpoint.';
$this->writeToLog("Error: " . $errorMessage);
return null;
}
$tokenData = json_decode($rawTokenData, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($tokenData['access_token'])) {
$this->writeToLog("Failed to decode token response or access_token missing. Error: " . json_last_error_msg());
$this->writeToLog("Raw Token Response: " . $rawTokenData);
$errorMessage = 'Failed to retrieve a valid access token.';
if (isset($tokenData['error_description'])) {
$errorMessage .= ' Provider message: ' . $tokenData['error_description'];
} elseif (isset($tokenData['error'])) {
$errorMessage .= ' Provider error: ' . $tokenData['error'];
} elseif (isset($tokenData['message'])) {
$errorMessage .= ' Provider message: ' . $tokenData['message'];
}
return null;
}
$accessToken = $tokenData['access_token'];
$this->writeToLog("Access token obtained successfully.");
// Fetch user information
$userInfoResponse = $this->getRequest($this->userInfoUri, [
'Authorization: Bearer ' . $accessToken,
'Accept: application/json'
]);
// Log the raw user information response for debugging
$this->writeToLog("Raw UserInfo Response: " . $userInfoResponse);
if ($userInfoResponse === false) {
$errorMessage = 'Failed to communicate with the user info endpoint.';
$this->writeToLog("Error: " . $errorMessage);
return null;
}
$userInfo = json_decode($userInfoResponse, true);
// /api/me response
if (json_last_error() !== JSON_ERROR_NONE || !isset($userInfo['user']) || !isset($userInfo['user']['user_id']) || !isset($userInfo['user']['username'])) {
$this->writeToLog("User info decode error or missing required fields (user, user.user_id, user.username). Error: " . json_last_error_msg());
$this->writeToLog("Raw UserInfo Response: " . $userInfoResponse);
$errorMessage = 'Failed to retrieve valid user information structure (user_id, username).';
return null;
}
$xenforoUser = $userInfo['user'];
$userId = $xenforoUser['user_id'];
$username = $xenforoUser['username'];
$this->writeToLog("User info retrieved: ID=" . $userId . ", Username=" . $username);
$this->writeToLog("Original XenForo Username: " . $username);
$userNameUtils = MediaWikiServices::getInstance()->getUserNameUtils();
$mwUsername = trim($username);
$mwUsername = str_replace(' ', '_', $mwUsername);
if (!$userNameUtils->isValid($mwUsername)) {
$this->writeToLog("Cleaned Username '" . $mwUsername . "' is invalid, using fallback.");
$mwUsername = 'XFUser_' . $userId;
if (!$userNameUtils->isValid($mwUsername)) {
$mwUsername = 'XFUser_' . uniqid();
$this->writeToLog("Using uniqid fallback: " . $mwUsername);
}
} else {
$mwUsername = $userNameUtils->getCanonical($mwUsername);
}
$this->writeToLog("Final MediaWiki Username: " . $mwUsername);
// Validate and sanitize username for MediaWiki
$userNameUtils = MediaWikiServices::getInstance()->getUserNameUtils();
$mwUsername = trim($username);
$mwUsername = str_replace(' ', '_', $mwUsername);
if (!$userNameUtils->isValid($mwUsername)) {
$this->writeToLog("Original username '" . $username . "' ('" . $mwUsername . "') is invalid for MediaWiki. Generating fallback.");
$mwUsername = 'XFUser_' . $userId;.
if (!$userNameUtils->isValid($mwUsername)) {
$mwUsername = 'XFUser_' . uniqid();
$this->writeToLog("Primary fallback invalid, using uniqid fallback: " . $mwUsername);
}
} else {
$mwUsername = $userNameUtils->getCanonical($mwUsername); // Canonicalize valid username
}
$this->writeToLog("Using MediaWiki Username: " . $mwUsername);
// Check if parent requires a specific return type if ?MWUserIdentity still causes issues
return new MWUserIdentity( (string)$userId, $mwUsername ); // Ensure ID is string if needed by MW/PluggableAuth
}
/**
* Initiates the login process by generating the authorization URL.
*/
public function login(?string &$key, ?string &$secret, ?string &$authUrl): bool {
$this->writeToLog("login called. Generating authorization URL.");
// Validate that authUri is set
if (empty($this->authUri)) {
$this->writeToLog("Error: authUri is not set. Cannot generate authorization URL.");
return false;
}
$params = [
'response_type' => 'code',
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUri,
'scope' => $this->scopes, // Use configured scope(s)
// 'state' => $generatedState // Add if handling state manually
];
$authUrl = $this->authUri . '?' . http_build_query($params);
$this->writeToLog("Authorization URL generated: " . $authUrl);
// assignments for $key and $secret **
$key = 'oauth_key_placeholder'; // REMOVE?
$secret = 'oauth_secret_placeholder'; // REMOVE?
$this->writeToLog("login output: Key=" . $key . ", Secret=" . $secret);
$this->writeToLog("Authorization code dynamically set: " . $key);
return true; // Indicates the auth URL is ready
}
public function logout(MWUserIdentity &$user): void {
$this->writeToLog("logout called for user ID: " . $user->getId());
return;
}
public function saveExtraAttributes(int $id): void {
// This method is called by PluggableAuth after authentication.
// $id is the MediaWiki user ID.
// Fetch details (like email, real name) using the access token (if stored)
// or perhaps stored during getUser, and update the MediaWiki user.
// Requires access to the access token or the user info retrieved earlier.
// WSOAuth might provide ways to store/retrieve this data.
$this->writeToLog("saveExtraAttributes called for MediaWiki user ID: " . $id . ". (Not Implemented)");
}
}