- Added GitHub spec-kit for development workflow - Standardized file signatures to Descomplicar® format - Updated development configuration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
692 lines
22 KiB
PHP
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';
|
|
}
|
|
} |