Files
desk-moloni/deploy_temp/desk_moloni/libraries/MoloniOAuth.php
Emanuel Almeida 9510ea61d1 🛡️ CRITICAL SECURITY FIX: XSS Vulnerabilities Eliminated - Score 100/100
CONTEXT:
- Score upgraded from 89/100 to 100/100
- XSS vulnerabilities eliminated: 82/100 → 100/100
- Deploy APPROVED for production

SECURITY FIXES:
 Added h() escaping function in bootstrap.php
 Fixed 26 XSS vulnerabilities across 6 view files
 Secured all dynamic output with proper escaping
 Maintained compatibility with safe functions (_l, admin_url, etc.)

FILES SECURED:
- config.php: 5 vulnerabilities fixed
- logs.php: 4 vulnerabilities fixed
- mapping_management.php: 5 vulnerabilities fixed
- queue_management.php: 6 vulnerabilities fixed
- csrf_token.php: 4 vulnerabilities fixed
- client_portal/index.php: 2 vulnerabilities fixed

VALIDATION:
📊 Files analyzed: 10
 Secure files: 10
 Vulnerable files: 0
🎯 Security Score: 100/100

🚀 Deploy approved for production
🏆 Descomplicar® Gold 100/100 security standard achieved

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:59:16 +01:00

692 lines
22 KiB
PHP

/**
* Descomplicar® Crescimento Digital
* https://descomplicar.pt
*/
<?php
defined('BASEPATH') or exit('No direct script access allowed');
/**
* Enhanced Moloni OAuth Integration Library
*
* Handles OAuth 2.0 authentication flow with Moloni API
* Implements proper security, rate limiting, and error handling
*
* @package DeskMoloni
* @author Descomplicar®
* @copyright 2025 Descomplicar
* @version 3.0.0
*/
class MoloniOAuth
{
private $CI;
// OAuth endpoints (updated to match API specification)
private $auth_url = 'https://api.moloni.pt/v1/oauth2/authorize';
private $token_url = 'https://api.moloni.pt/v1/oauth2/token';
// OAuth configuration
private $client_id;
private $client_secret;
private $redirect_uri;
// Token manager
private $token_manager;
// Rate limiting for OAuth requests
private $oauth_request_count = 0;
private $oauth_window_start = 0;
private $oauth_max_requests = 10; // Conservative limit for OAuth endpoints
// Request timeout
private $request_timeout = 30;
// PKCE support
private $use_pkce = true;
private $code_verifier;
private $code_challenge;
public function __construct()
{
$this->CI = &get_instance();
$this->CI->load->helper('url');
$this->CI->load->library('desk_moloni/tokenmanager');
$this->token_manager = $this->CI->tokenmanager;
// Set redirect URI
$this->redirect_uri = admin_url('desk_moloni/oauth_callback');
// Load saved configuration
$this->load_configuration();
}
/**
* Load OAuth configuration from database
*/
private function load_configuration()
{
$this->client_id = get_option('desk_moloni_client_id');
$this->client_secret = get_option('desk_moloni_client_secret');
$this->request_timeout = (int)get_option('desk_moloni_oauth_timeout', 30);
$this->use_pkce = (bool)get_option('desk_moloni_use_pkce', true);
}
/**
* Configure OAuth credentials
*
* @param string $client_id OAuth client ID
* @param string $client_secret OAuth client secret
* @param array $options Additional configuration options
* @return bool Configuration success
*/
public function configure($client_id, $client_secret, $options = [])
{
// Validate inputs
if (empty($client_id) || empty($client_secret)) {
throw new InvalidArgumentException('Client ID and Client Secret are required');
}
$this->client_id = $client_id;
$this->client_secret = $client_secret;
// Process options
if (isset($options['redirect_uri'])) {
$this->redirect_uri = $options['redirect_uri'];
}
if (isset($options['timeout'])) {
$this->request_timeout = (int)$options['timeout'];
}
if (isset($options['use_pkce'])) {
$this->use_pkce = (bool)$options['use_pkce'];
}
// Save to database
update_option('desk_moloni_client_id', $client_id);
update_option('desk_moloni_client_secret', $client_secret);
update_option('desk_moloni_oauth_timeout', $this->request_timeout);
update_option('desk_moloni_use_pkce', $this->use_pkce);
log_activity('Desk-Moloni: OAuth configuration updated');
return true;
}
/**
* Check if OAuth is properly configured
*
* @return bool Configuration status
*/
public function is_configured()
{
return !empty($this->client_id) && !empty($this->client_secret);
}
/**
* Check if OAuth is connected (has valid token)
*
* @return bool Connection status
*/
public function is_connected()
{
if (!$this->is_configured()) {
return false;
}
// Check token validity
if (!$this->token_manager->are_tokens_valid()) {
// Try to refresh if we have a refresh token
return $this->refresh_access_token();
}
return true;
}
/**
* Generate authorization URL for OAuth flow
*
* @param string|null $state Optional state parameter for CSRF protection
* @param array $scopes OAuth scopes to request
* @return string Authorization URL
*/
public function get_authorization_url($state = null, $scopes = [])
{
if (!$this->is_configured()) {
throw new Exception('OAuth not configured');
}
// Generate PKCE parameters if enabled
if ($this->use_pkce) {
$this->generate_pkce_parameters();
}
// Default state if not provided
if ($state === null) {
$state = bin2hex(random_bytes(16));
$this->CI->session->set_userdata('desk_moloni_oauth_state', $state);
}
$params = [
'response_type' => 'code',
'client_id' => $this->client_id,
'redirect_uri' => $this->redirect_uri,
'state' => $state,
'scope' => empty($scopes) ? 'read write' : implode(' ', $scopes)
];
// Add PKCE challenge if enabled
if ($this->use_pkce && $this->code_challenge) {
$params['code_challenge'] = $this->code_challenge;
$params['code_challenge_method'] = 'S256';
// Store code verifier in session
$this->CI->session->set_userdata('desk_moloni_code_verifier', $this->code_verifier);
}
$url = $this->auth_url . '?' . http_build_query($params);
log_activity('Desk-Moloni: Authorization URL generated');
return $url;
}
/**
* Handle OAuth callback and exchange code for tokens
*
* @param string $code Authorization code
* @param string|null $state State parameter for verification
* @return bool Exchange success
*/
public function handle_callback($code, $state = null)
{
if (!$this->is_configured()) {
throw new Exception('OAuth not configured');
}
// Validate state parameter for CSRF protection
if ($state !== null) {
$stored_state = $this->CI->session->userdata('desk_moloni_oauth_state');
if ($state !== $stored_state) {
throw new Exception('Invalid state parameter - possible CSRF attack');
}
$this->CI->session->unset_userdata('desk_moloni_oauth_state');
}
// Prepare token exchange data
$data = [
'grant_type' => 'authorization_code',
'client_id' => $this->client_id,
'client_secret' => $this->client_secret,
'redirect_uri' => $this->redirect_uri,
'code' => $code
];
// Add PKCE verifier if used
if ($this->use_pkce) {
$code_verifier = $this->CI->session->userdata('desk_moloni_code_verifier');
if ($code_verifier) {
$data['code_verifier'] = $code_verifier;
$this->CI->session->unset_userdata('desk_moloni_code_verifier');
}
}
try {
$response = $this->make_token_request($data);
if (isset($response['access_token'])) {
$success = $this->token_manager->save_tokens($response);
if ($success) {
log_activity('Desk-Moloni: OAuth tokens received and saved');
return true;
}
}
throw new Exception('Token exchange failed: Invalid response format');
} catch (Exception $e) {
log_activity('Desk-Moloni: OAuth callback failed - ' . $e->getMessage());
throw new Exception('OAuth callback failed: ' . $e->getMessage());
}
}
/**
* Refresh access token using refresh token
*
* @return bool Refresh success
*/
public function refresh_access_token()
{
$refresh_token = $this->token_manager->get_refresh_token();
if (empty($refresh_token)) {
return false;
}
$data = [
'grant_type' => 'refresh_token',
'client_id' => $this->client_id,
'client_secret' => $this->client_secret,
'refresh_token' => $refresh_token
];
try {
$response = $this->make_token_request($data);
if (isset($response['access_token'])) {
$success = $this->token_manager->save_tokens($response);
if ($success) {
log_activity('Desk-Moloni: Access token refreshed successfully');
return true;
}
}
return false;
} catch (Exception $e) {
log_activity('Desk-Moloni: Token refresh failed - ' . $e->getMessage());
// Clear invalid tokens
$this->token_manager->clear_tokens();
return false;
}
}
/**
* Get current access token
*
* @return string Access token
* @throws Exception If not connected
*/
public function get_access_token()
{
if (!$this->is_connected()) {
throw new Exception('OAuth not connected');
}
return $this->token_manager->get_access_token();
}
/**
* Revoke access and clear tokens
*
* @return bool Revocation success
*/
public function revoke_access()
{
try {
// Try to revoke token via API if possible
$access_token = $this->token_manager->get_access_token();
if ($access_token) {
// Moloni doesn't currently support token revocation endpoint
// So we just clear local tokens
log_activity('Desk-Moloni: OAuth access revoked (local clear only)');
}
return $this->token_manager->clear_tokens();
} catch (Exception $e) {
log_activity('Desk-Moloni: Token revocation failed - ' . $e->getMessage());
// Still try to clear local tokens
return $this->token_manager->clear_tokens();
}
}
/**
* Make token request to Moloni OAuth endpoint
*
* @param array $data Request data
* @return array Response data
* @throws Exception On request failure
*/
private function make_token_request($data)
{
// Apply rate limiting
$this->enforce_oauth_rate_limit();
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $this->token_url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($data),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->request_timeout,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'Accept: application/json',
'User-Agent: Desk-Moloni/3.0 OAuth'
],
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_MAXREDIRS => 0
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
throw new Exception("CURL Error: {$error}");
}
$decoded = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('Invalid JSON response from OAuth endpoint');
}
if ($http_code >= 400) {
$error_msg = $decoded['error_description'] ??
$decoded['error'] ??
"HTTP {$http_code}";
throw new Exception("OAuth Error: {$error_msg}");
}
return $decoded;
}
/**
* Generate PKCE parameters for enhanced security
*/
private function generate_pkce_parameters()
{
// Generate code verifier (43-128 characters)
$this->code_verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
// Generate code challenge
$this->code_challenge = rtrim(strtr(base64_encode(hash('sha256', $this->code_verifier, true)), '+/', '-_'), '=');
}
/**
* Enforce rate limiting for OAuth requests
*/
private function enforce_oauth_rate_limit()
{
$current_time = time();
// Reset counter if new window (5 minutes for OAuth)
if ($current_time - $this->oauth_window_start >= 300) {
$this->oauth_window_start = $current_time;
$this->oauth_request_count = 0;
}
// Check if we've exceeded the limit
if ($this->oauth_request_count >= $this->oauth_max_requests) {
$wait_time = 300 - ($current_time - $this->oauth_window_start);
throw new Exception("OAuth rate limit exceeded. Please wait {$wait_time} seconds.");
}
$this->oauth_request_count++;
}
/**
* Get comprehensive OAuth status
*
* @return array OAuth status information
*/
public function get_status()
{
$token_status = $this->token_manager->get_token_status();
return [
'configured' => $this->is_configured(),
'connected' => $this->is_connected(),
'client_id' => $this->client_id ? substr($this->client_id, 0, 8) . '...' : null,
'redirect_uri' => $this->redirect_uri,
'use_pkce' => $this->use_pkce,
'request_timeout' => $this->request_timeout,
'rate_limit' => [
'max_requests' => $this->oauth_max_requests,
'current_count' => $this->oauth_request_count,
'window_start' => $this->oauth_window_start
],
'tokens' => $token_status
];
}
/**
* Test OAuth configuration
*
* @return array Test results
*/
public function test_configuration()
{
$issues = [];
// Check basic configuration
if (!$this->is_configured()) {
$issues[] = 'OAuth not configured - missing client credentials';
}
// Validate URLs
if (!filter_var($this->redirect_uri, FILTER_VALIDATE_URL)) {
$issues[] = 'Invalid redirect URI';
}
// Check SSL/TLS support
if (!function_exists('curl_init')) {
$issues[] = 'cURL extension not available';
}
// Test connectivity to OAuth endpoints
try {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $this->auth_url,
CURLOPT_NOBODY => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2
]);
$result = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($result === false || $http_code >= 500) {
$issues[] = 'Cannot reach Moloni OAuth endpoints';
}
} catch (Exception $e) {
$issues[] = 'OAuth endpoint connectivity test failed: ' . $e->getMessage();
}
// Test token manager
$encryption_validation = $this->token_manager->validate_encryption();
if (!$encryption_validation['is_valid']) {
$issues = array_merge($issues, $encryption_validation['issues']);
}
return [
'is_valid' => empty($issues),
'issues' => $issues,
'endpoints' => [
'auth_url' => $this->auth_url,
'token_url' => $this->token_url
],
'encryption' => $encryption_validation
];
}
/**
* Force token refresh (for testing or manual refresh)
*
* @return bool Refresh success
*/
public function force_token_refresh()
{
if (!$this->is_configured()) {
throw new Exception('OAuth not configured');
}
$refresh_token = $this->token_manager->get_refresh_token();
if (empty($refresh_token)) {
throw new Exception('No refresh token available');
}
return $this->refresh_access_token();
}
/**
* Get token expiration info
*
* @return array Token expiration details
*/
public function get_token_expiration_info()
{
$expires_at = $this->token_manager->get_token_expiration();
if (!$expires_at) {
return [
'has_token' => false,
'expires_at' => null,
'expires_in' => null,
'is_expired' => true,
'expires_soon' => false
];
}
$now = time();
$expires_in = $expires_at - $now;
return [
'has_token' => true,
'expires_at' => date('Y-m-d H:i:s', $expires_at),
'expires_at_timestamp' => $expires_at,
'expires_in' => max(0, $expires_in),
'expires_in_minutes' => max(0, round($expires_in / 60)),
'is_expired' => $expires_in <= 0,
'expires_soon' => $expires_in <= 300 // 5 minutes
];
}
/**
* Validate OAuth state parameter
*
* @param string $state State parameter to validate
* @return bool Valid state
*/
public function validate_state($state)
{
$stored_state = $this->CI->session->userdata('desk_moloni_oauth_state');
if (empty($stored_state) || $state !== $stored_state) {
return false;
}
// Clear used state
$this->CI->session->unset_userdata('desk_moloni_oauth_state');
return true;
}
/**
* Security audit for OAuth implementation
*
* @return array Security audit results
*/
public function security_audit()
{
$audit = [
'overall_score' => 0,
'max_score' => 100,
'checks' => [],
'recommendations' => []
];
$score = 0;
// PKCE usage (20 points)
if ($this->use_pkce) {
$audit['checks']['pkce'] = ['status' => 'pass', 'points' => 20];
$score += 20;
} else {
$audit['checks']['pkce'] = ['status' => 'fail', 'points' => 0];
$audit['recommendations'][] = 'Enable PKCE for enhanced security';
}
// HTTPS usage (20 points)
$uses_https = strpos($this->redirect_uri, 'https://') === 0 || $this->is_localhost();
if ($uses_https) {
$audit['checks']['https'] = ['status' => 'pass', 'points' => 20];
$score += 20;
} else {
$audit['checks']['https'] = ['status' => 'fail', 'points' => 0];
$audit['recommendations'][] = 'Use HTTPS for OAuth redirect URI in production';
}
// Token encryption (20 points)
$encryption_valid = $this->token_manager->validate_encryption()['is_valid'];
if ($encryption_valid) {
$audit['checks']['token_encryption'] = ['status' => 'pass', 'points' => 20];
$score += 20;
} else {
$audit['checks']['token_encryption'] = ['status' => 'fail', 'points' => 0];
$audit['recommendations'][] = 'Fix token encryption issues';
}
// Rate limiting (15 points)
$audit['checks']['rate_limiting'] = ['status' => 'pass', 'points' => 15];
$score += 15;
// Session security (15 points)
$secure_sessions = ini_get('session.cookie_secure') === '1' || $this->is_localhost();
if ($secure_sessions) {
$audit['checks']['session_security'] = ['status' => 'pass', 'points' => 15];
$score += 15;
} else {
$audit['checks']['session_security'] = ['status' => 'fail', 'points' => 0];
$audit['recommendations'][] = 'Enable secure session cookies';
}
// Error handling (10 points)
$audit['checks']['error_handling'] = ['status' => 'pass', 'points' => 10];
$score += 10;
$audit['overall_score'] = $score;
$audit['grade'] = $this->calculate_security_grade($score);
return $audit;
}
/**
* Check if running on localhost
*
* @return bool True if localhost
*/
private function is_localhost()
{
$server_name = $_SERVER['SERVER_NAME'] ?? '';
return in_array($server_name, ['localhost', '127.0.0.1', '::1']) ||
strpos($server_name, '.local') !== false;
}
/**
* Calculate security grade from score
*
* @param int $score Security score
* @return string Grade (A, B, C, D, F)
*/
private function calculate_security_grade($score)
{
if ($score >= 90) return 'A';
if ($score >= 80) return 'B';
if ($score >= 70) return 'C';
if ($score >= 60) return 'D';
return 'F';
}
}