From fe60d87dcb6d0729005cd32d99dbde6412ba0318 Mon Sep 17 00:00:00 2001 From: Dave Porter Date: Wed, 7 Jan 2026 12:43:42 -0500 Subject: [PATCH] Add Microsoft Azure AD SSO authentication - Integrated MSAL authentication for web pages - Added AuthMiddleware.php for SSO orchestration - Added JWTValidator.php for token validation - Protected report.php and webhook_caller.php - Firebase PHP-JWT for token verification - SSO can be disabled for local development - Complete SSO setup documentation - Environment-based configuration --- .env.example | 6 + AuthMiddleware.php | 409 +++++++++++++++++++++++++++++++++++++++++++++ JWTValidator.php | 201 ++++++++++++++++++++++ README.md | 35 +++- SSO-SETUP.md | 318 +++++++++++++++++++++++++++++++++++ auth.php | 118 +++++++++++++ composer.json | 20 +++ config.php | 30 ++++ env_loader.php | 35 ++++ report.php | 5 + webhook_caller.php | 5 + 11 files changed, 1178 insertions(+), 4 deletions(-) create mode 100644 AuthMiddleware.php create mode 100644 JWTValidator.php create mode 100644 SSO-SETUP.md create mode 100644 auth.php create mode 100644 composer.json create mode 100644 config.php create mode 100644 env_loader.php diff --git a/.env.example b/.env.example index b0de2e0..d26a51a 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,9 @@ SENDER_EMAIL=reports@your-domain.com # Email Recipients (comma-separated) REPORT_RECIPIENTS=user1@example.com,user2@example.com + +# SSO Configuration (Microsoft Azure AD / MSAL) +# Set SSO_ENABLED=true for production, false for local development without SSO +SSO_ENABLED=true +SSO_TENANT_ID=your-azure-tenant-id +SSO_CLIENT_ID=your-azure-client-id diff --git a/AuthMiddleware.php b/AuthMiddleware.php new file mode 100644 index 0000000..bfa7e0a --- /dev/null +++ b/AuthMiddleware.php @@ -0,0 +1,409 @@ +ssoEnabled = SSO_ENABLED; + $this->tenantId = SSO_TENANT_ID; + $this->clientId = SSO_CLIENT_ID; + + // Only initialize validator if SSO is enabled + if ($this->ssoEnabled) { + require_once __DIR__ . '/JWTValidator.php'; + $this->validator = new JWTValidator($this->tenantId, $this->clientId); + } + } + + /** + * Check if SSO is enabled + * + * @return bool + */ + public function isSSOEnabled() { + return $this->ssoEnabled; + } + + /** + * Check if user is authenticated + * + * @return array ['authenticated' => bool, 'user' => array|null, 'error' => string|null] + */ + public function isAuthenticated() { + // If SSO is disabled, return authenticated with mock user + if (!$this->ssoEnabled) { + return [ + 'authenticated' => true, + 'user' => [ + 'name' => 'Local Developer', + 'preferred_username' => 'dev@localhost' + ] + ]; + } + + // Get token from cookie + $token = $this->getTokenFromCookie(); + if (!$token) { + return ['authenticated' => false, 'error' => 'No authentication token found']; + } + + // Validate token + $validation = $this->validator->validateToken($token); + if (!$validation['valid']) { + return ['authenticated' => false, 'error' => $validation['error']]; + } + + return ['authenticated' => true, 'user' => $validation['payload']]; + } + + /** + * Require authentication - blocks if not authenticated + * + * @return array User data + */ + public function requireAuth() { + // If SSO is disabled, return mock user + if (!$this->ssoEnabled) { + return [ + 'name' => 'Local Developer', + 'preferred_username' => 'dev@localhost' + ]; + } + + // Check authentication + $auth = $this->isAuthenticated(); + + if (!$auth['authenticated']) { + $this->handleUnauthorized($auth['error'] ?? 'Authentication required'); + exit; + } + + return $auth['user']; + } + + /** + * Set authentication token (after login) + * + * @param string $token JWT token from MSAL + * @return array ['success' => bool, 'user' => array|null, 'error' => string|null] + */ + public function setAuthToken($token) { + if (!$this->ssoEnabled) { + return ['success' => false, 'error' => 'SSO is disabled']; + } + + // Validate token + $validation = $this->validator->validateToken($token); + if (!$validation['valid']) { + return ['success' => false, 'error' => $validation['error']]; + } + + // Set httpOnly cookie with security options + $cookieOptions = [ + 'expires' => time() + (24 * 60 * 60), // 24 hours + 'path' => '/', + 'domain' => '', + 'secure' => isset($_SERVER['HTTPS']), // Only over HTTPS in production + 'httponly' => true, + 'samesite' => 'Lax' + ]; + + setcookie('auth_token', $token, $cookieOptions); + + return ['success' => true, 'user' => $validation['payload']]; + } + + /** + * Clear authentication token (logout) + */ + public function clearAuthToken() { + setcookie('auth_token', '', [ + 'expires' => time() - 3600, + 'path' => '/', + 'httponly' => true + ]); + } + + /** + * Get token from cookie + * + * @return string|null + */ + private function getTokenFromCookie() { + return isset($_COOKIE['auth_token']) ? $_COOKIE['auth_token'] : null; + } + + /** + * Handle unauthorized access + * + * @param string $error Error message + */ + private function handleUnauthorized($error) { + if ($this->isAjaxRequest()) { + // For AJAX requests, return JSON + header('Content-Type: application/json'); + http_response_code(401); + echo json_encode([ + 'error' => 'Authentication required', + 'message' => $error, + 'requiresAuth' => true + ]); + } else { + // For page requests, show login interface + $this->showLoginPage($error); + } + } + + /** + * Check if request is AJAX + * + * @return bool + */ + private function isAjaxRequest() { + return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && + strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'; + } + + /** + * Show login page with MSAL integration + * + * @param string $error Optional error message + */ + public function showLoginPage($error = '') { + ?> + + + + + + Sign In - VEO3 Usage Report + + + + + + + +
+ +

VEO3 Usage Report

+ + + +
+ +
+ + + + +
+ Sign in with your Microsoft account to access usage reports +
+
+ + + + + tenantId = $tenantId; + $this->clientId = $clientId; + } + + /** + * Validate a JWT token + * + * @param string $token The JWT token to validate + * @return array ['valid' => bool, 'payload' => array|null, 'error' => string|null] + */ + public function validateToken($token) { + if (empty($token)) { + return ['valid' => false, 'error' => 'Token is empty']; + } + + try { + // Get public keys from Azure AD + $jwks = $this->getJWKS(); + if (!$jwks) { + return ['valid' => false, 'error' => 'Could not retrieve public keys from Azure AD']; + } + + // Convert JWKS to Key objects + $keys = JWK::parseKeySet($jwks); + + // Decode and validate the JWT + $decoded = JWT::decode($token, $keys); + $payload = (array) $decoded; + + // Validate claims + $validation = $this->validateClaims($payload); + if (!$validation['valid']) { + return $validation; + } + + return ['valid' => true, 'payload' => $payload]; + + } catch (\Firebase\JWT\ExpiredException $e) { + return ['valid' => false, 'error' => 'Token has expired']; + } catch (\Firebase\JWT\SignatureInvalidException $e) { + return ['valid' => false, 'error' => 'Token signature is invalid']; + } catch (\Firebase\JWT\BeforeValidException $e) { + return ['valid' => false, 'error' => 'Token is not yet valid']; + } catch (Exception $e) { + return ['valid' => false, 'error' => 'JWT validation failed: ' . $e->getMessage()]; + } + } + + /** + * Validate JWT claims + * + * @param array $payload The decoded JWT payload + * @return array ['valid' => bool, 'error' => string|null] + */ + private function validateClaims($payload) { + $now = time(); + + // Check expiration (exp claim) + if (isset($payload['exp']) && $payload['exp'] < $now) { + return ['valid' => false, 'error' => 'Token has expired']; + } + + // Check not-before (nbf claim) + if (isset($payload['nbf']) && $payload['nbf'] > $now) { + return ['valid' => false, 'error' => 'Token is not yet valid']; + } + + // Validate audience (aud claim) - must be our client ID or Microsoft Graph + if (isset($payload['aud'])) { + $validAudiences = [ + $this->clientId, + '00000003-0000-0000-c000-000000000000', // Microsoft Graph + 'https://graph.microsoft.com' + ]; + + if (!in_array($payload['aud'], $validAudiences)) { + return ['valid' => false, 'error' => 'Invalid audience: ' . $payload['aud']]; + } + } + + // Validate issuer (iss claim) - must be from Azure AD tenant + if (isset($payload['iss'])) { + $validIssuers = [ + "https://login.microsoftonline.com/{$this->tenantId}/v2.0", + "https://login.microsoftonline.com/{$this->tenantId}/", + "https://sts.windows.net/{$this->tenantId}/", + "https://login.microsoftonline.com/common/v2.0" + ]; + + if (!in_array($payload['iss'], $validIssuers)) { + return ['valid' => false, 'error' => 'Invalid issuer: ' . $payload['iss']]; + } + } + + return ['valid' => true]; + } + + /** + * Get JWKS (JSON Web Key Set) from Azure AD + * + * @return array|null + */ + private function getJWKS() { + // Return cached JWKS if still valid + if ($this->jwksCache && (time() - $this->jwksCacheTime) < $this->jwksCacheDuration) { + return $this->jwksCache; + } + + // Get OpenID configuration from Azure AD + $configUrl = "https://login.microsoftonline.com/{$this->tenantId}/v2.0/.well-known/openid-configuration"; + + $config = $this->fetchJson($configUrl); + if (!$config || !isset($config['jwks_uri'])) { + error_log("Failed to get OpenID configuration from: $configUrl"); + return null; + } + + // Fetch JWKS from jwks_uri + $jwks = $this->fetchJson($config['jwks_uri']); + + if (!$jwks || !isset($jwks['keys'])) { + error_log("Failed to get JWKS from: " . $config['jwks_uri']); + return null; + } + + // Ensure all keys have the 'alg' parameter + foreach ($jwks['keys'] as &$key) { + if (!isset($key['alg'])) { + // Default to RS256 for RSA keys + if (isset($key['kty']) && $key['kty'] === 'RSA') { + $key['alg'] = 'RS256'; + } + } + } + + // Cache the JWKS + $this->jwksCache = $jwks; + $this->jwksCacheTime = time(); + + return $jwks; + } + + /** + * Fetch JSON from a URL + * + * @param string $url + * @return array|null + */ + private function fetchJson($url) { + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError) { + error_log("CURL Error fetching $url: $curlError"); + return null; + } + + if ($httpCode !== 200) { + error_log("HTTP Error $httpCode fetching $url"); + return null; + } + + $json = json_decode($response, true); + if (json_last_error() !== JSON_ERROR_NONE) { + error_log("JSON decode error fetching $url: " . json_last_error_msg()); + return null; + } + + return $json; + } +} diff --git a/README.md b/README.md index 30d4b14..939a573 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # VEO3 Usage Report System -Automated reporting system for VEO3 video generation usage analytics. +Automated reporting system for VEO3 video generation usage analytics with Microsoft Azure AD Single Sign-On. ## Features - **PHP Web Interface**: Interactive dashboard for viewing usage reports +- **SSO Authentication**: Microsoft Azure AD (MSAL) integration for secure access - **Python Automation**: Automated daily/weekly/monthly email reports via SMTP (Mailgun) - **Granular Analytics**: - Last 24 hours activity @@ -15,14 +16,31 @@ Automated reporting system for VEO3 video generation usage analytics. ## Setup -### PHP Web Reports +### PHP Web Reports with SSO -1. Start PHP development server: +1. **Install PHP dependencies:** + ```bash + composer install + ``` + +2. **Configure SSO (see [SSO-SETUP.md](SSO-SETUP.md)):** + ```bash + cp .env.example .env + nano .env # Add Azure AD tenant and client IDs + ``` + +3. **For local development (disable SSO):** + ```bash + # In .env + SSO_ENABLED=false + ``` + +4. **Start PHP development server:** ```bash php -S localhost:8000 ``` -2. Access the interfaces: +5. **Access the interfaces:** - `http://localhost:8000/webhook_caller.php` - Fetch data from webhook - `http://localhost:8000/report.php` - View full report @@ -134,6 +152,15 @@ The Python script: ### Configuration - `.env.example` - Configuration template - `.env` - Your configuration (not in git) +- `config.php` - SSO and session configuration +- `env_loader.php` - Environment variable loader + +### SSO Files +- `AuthMiddleware.php` - Main authentication class +- `JWTValidator.php` - JWT token validator +- `auth.php` - Authentication API endpoint +- `composer.json` - PHP dependencies +- `SSO-SETUP.md` - Complete SSO setup guide ### Generated Files - `webhook_response.json` - Cached webhook data (auto-generated) diff --git a/SSO-SETUP.md b/SSO-SETUP.md new file mode 100644 index 0000000..1210dc0 --- /dev/null +++ b/SSO-SETUP.md @@ -0,0 +1,318 @@ +# SSO Setup Guide - Microsoft Azure AD (MSAL) + +The VEO3 Report System uses Microsoft Azure AD for Single Sign-On authentication. This protects both the web dashboard and webhook caller pages. + +## Prerequisites + +- Microsoft Azure AD tenant +- Permission to register applications in Azure AD +- PHP 7.4 or higher +- Composer (PHP package manager) + +## Setup Steps + +### 1. Install PHP Dependencies + +```bash +cd /path/to/veo3-report +composer install +``` + +This installs the `firebase/php-jwt` library required for token validation. + +### 2. Register Application in Azure AD + +1. **Login to Azure Portal**: https://portal.azure.com +2. **Navigate to**: Azure Active Directory → App registrations → New registration + +3. **Register the application:** + - **Name**: VEO3 Usage Report + - **Supported account types**: Accounts in this organizational directory only + - **Redirect URI**: Web → `https://your-domain.com/report.php` (and add `/webhook_caller.php`) + - Click **Register** + +4. **Note these values:** + - **Application (client) ID**: Copy this + - **Directory (tenant) ID**: Copy this + +5. **Configure Authentication:** + - Go to **Authentication** in left menu + - Under **Implicit grant and hybrid flows**: + - ✅ Check **ID tokens** + - ✅ Check **Access tokens** + - Click **Save** + +6. **Configure API Permissions:** + - Go to **API permissions** in left menu + - Should already have **Microsoft Graph** → **User.Read** (delegated) + - This is sufficient for SSO + +### 3. Configure Environment Variables + +Edit your `.env` file: + +```bash +# SSO Configuration +SSO_ENABLED=true +SSO_TENANT_ID=your-tenant-id-here +SSO_CLIENT_ID=your-client-id-here +``` + +**For Local Development (disable SSO):** +```bash +SSO_ENABLED=false +``` + +When SSO is disabled, you'll be logged in as "Local Developer" automatically. + +### 4. Deploy to Server + +If deploying with the automated script: + +```bash +# Ensure .env is configured +nano .env + +# Run deployment +sudo ./deploy.sh +``` + +The deployment script will: +- Copy SSO files +- Install Composer dependencies +- Set up the systemd service + +### 5. Configure Web Server + +#### Apache (.htaccess) + +```apache +# Enable PHP +AddHandler application/x-httpd-php .php + +# Security headers +Header always set X-Frame-Options "SAMEORIGIN" +Header always set X-Content-Type-Options "nosniff" +Header always set X-XSS-Protection "1; mode=block" + +# Deny access to sensitive files + + Require all denied + + + + Require all denied + + + + Require all denied + +``` + +#### Nginx + +```nginx +location ~ \.php$ { + fastcgi_pass unix:/var/run/php/php7.4-fpm.sock; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; +} + +# Deny access to sensitive files +location ~ /\. { + deny all; +} + +location ~ composer\.(json|lock)$ { + deny all; +} + +# Security headers +add_header X-Frame-Options "SAMEORIGIN" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-XSS-Protection "1; mode=block" always; +``` + +## Testing SSO + +### 1. Test Locally (SSO Disabled) + +```bash +# In .env +SSO_ENABLED=false + +# Start PHP server +php -S localhost:8000 + +# Visit http://localhost:8000/report.php +# Should show reports without login +``` + +### 2. Test with SSO Enabled + +```bash +# In .env +SSO_ENABLED=true +SSO_TENANT_ID=your-tenant-id +SSO_CLIENT_ID=your-client-id + +# Visit https://your-domain.com/report.php +# Should redirect to Microsoft login +``` + +## How It Works + +### Authentication Flow + +1. **User visits protected page** (report.php or webhook_caller.php) +2. **AuthMiddleware checks** for valid authentication cookie +3. **If not authenticated:** + - Shows Microsoft login page + - User authenticates with Azure AD + - MSAL returns JWT token + - Token sent to `auth.php` for validation + - Valid token stored in secure cookie + - Page reloads with authenticated session +4. **If authenticated:** + - JWT token validated on each request + - User information available in `$user` variable + +### Security Features + +- ✅ **JWT validation** with Azure AD public keys +- ✅ **HttpOnly cookies** (not accessible to JavaScript) +- ✅ **Token expiration** checked (24-hour lifetime) +- ✅ **Audience validation** (ensures token is for this app) +- ✅ **Issuer validation** (ensures token from correct Azure tenant) +- ✅ **SameSite cookie** attribute (CSRF protection) +- ✅ **Secure cookies** over HTTPS in production + +### File Structure + +``` +veo3-report/ +├── AuthMiddleware.php # Main authentication class +├── JWTValidator.php # JWT token validator +├── auth.php # Authentication API endpoint +├── config.php # SSO configuration +├── env_loader.php # .env file loader +├── report.php # Protected: requires auth +├── webhook_caller.php # Protected: requires auth +└── composer.json # PHP dependencies +``` + +## Troubleshooting + +### "Could not retrieve public keys from Azure AD" + +**Cause**: Network issue or invalid tenant ID +**Fix**: +- Verify `SSO_TENANT_ID` in `.env` +- Check server can reach `login.microsoftonline.com` +- Check PHP error logs: `tail -f /var/log/apache2/error.log` + +### "Token signature is invalid" + +**Cause**: Token from wrong tenant or client +**Fix**: +- Verify `SSO_CLIENT_ID` matches Azure app registration +- Clear browser cookies and try again +- Ensure redirect URI matches exactly in Azure + +### "Invalid audience" + +**Cause**: Token not intended for this application +**Fix**: +- Verify `SSO_CLIENT_ID` in `.env` +- Check Azure app registration client ID + +### Login popup blocked + +**Cause**: Browser blocking popups +**Fix**: +- Allow popups for your domain +- Try different browser + +### Token expired + +**Cause**: Token older than 24 hours +**Fix**: +- Clear cookies and login again +- Automatic redirect to login should occur + +## Disabling SSO + +To temporarily disable SSO (for maintenance or testing): + +```bash +# In .env +SSO_ENABLED=false + +# Restart web server/PHP-FPM +sudo systemctl restart apache2 +# or +sudo systemctl restart nginx +``` + +When disabled: +- No login required +- Mock user "Local Developer" used +- Full access to all features + +## Adding More Protected Pages + +To protect additional PHP pages: + +```php +requireAuth(); + +// User is authenticated, $user contains: +// - name: Display name +// - preferred_username: Email address +?> +``` + +## User Logout + +Add a logout link: + +```html +Logout + + +``` + +## Production Checklist + +- [ ] Azure app registered with correct redirect URIs +- [ ] `.env` configured with correct tenant/client IDs +- [ ] `SSO_ENABLED=true` in `.env` +- [ ] Composer dependencies installed +- [ ] HTTPS enabled (required for production) +- [ ] Security headers configured in web server +- [ ] Error logs monitored for auth issues +- [ ] Test login/logout flow + +## Support + +For SSO issues: +1. Check PHP error logs +2. Check browser console for JavaScript errors +3. Verify Azure app configuration +4. Test with `SSO_ENABLED=false` to isolate issue diff --git a/auth.php b/auth.php new file mode 100644 index 0000000..4e40467 --- /dev/null +++ b/auth.php @@ -0,0 +1,118 @@ + 'Invalid request - action required']); + exit; +} + +$action = $input['action']; + +// Handle different actions +switch ($action) { + case 'login': + handleLogin($auth, $input); + break; + + case 'logout': + handleLogout($auth); + break; + + case 'status': + handleStatus($auth); + break; + + default: + http_response_code(400); + echo json_encode(['error' => 'Unknown action: ' . $action]); + break; +} + +/** + * Handle login action + */ +function handleLogin($auth, $input) { + if (!$auth->isSSOEnabled()) { + http_response_code(400); + echo json_encode(['error' => 'SSO is disabled']); + return; + } + + // Prefer ID token for validation, fallback to access token + $token = $input['idToken'] ?? $input['accessToken'] ?? null; + + if (!$token) { + http_response_code(400); + echo json_encode(['error' => 'Authentication token is required']); + return; + } + + // Validate and set token + $result = $auth->setAuthToken($token); + + if ($result['success']) { + echo json_encode([ + 'success' => true, + 'message' => 'Authentication successful', + 'user' => [ + 'name' => $result['user']['name'] ?? 'Unknown', + 'email' => $result['user']['preferred_username'] ?? $result['user']['upn'] ?? 'Unknown' + ] + ]); + } else { + http_response_code(401); + echo json_encode([ + 'success' => false, + 'error' => $result['error'] + ]); + } +} + +/** + * Handle logout action + */ +function handleLogout($auth) { + $auth->clearAuthToken(); + echo json_encode([ + 'success' => true, + 'message' => 'Logged out successfully' + ]); +} + +/** + * Handle status check action + */ +function handleStatus($auth) { + $authStatus = $auth->isAuthenticated(); + + if ($authStatus['authenticated']) { + echo json_encode([ + 'authenticated' => true, + 'sso_enabled' => $auth->isSSOEnabled(), + 'user' => [ + 'name' => $authStatus['user']['name'] ?? 'Unknown', + 'email' => $authStatus['user']['preferred_username'] ?? $authStatus['user']['upn'] ?? 'Unknown' + ] + ]); + } else { + http_response_code(401); + echo json_encode([ + 'authenticated' => false, + 'sso_enabled' => $auth->isSSOEnabled(), + 'error' => $authStatus['error'] ?? 'Not authenticated' + ]); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a03a9f7 --- /dev/null +++ b/composer.json @@ -0,0 +1,20 @@ +{ + "name": "veo3-report/usage-analytics", + "description": "VEO3 Usage Report System with MSAL Authentication", + "type": "project", + "require": { + "php": ">=7.4", + "firebase/php-jwt": "^6.0" + }, + "require-dev": {}, + "autoload": { + "classmap": [ + "JWTValidator.php", + "AuthMiddleware.php" + ] + }, + "config": { + "optimize-autoloader": true, + "sort-packages": true + } +} diff --git a/config.php b/config.php new file mode 100644 index 0000000..dff8311 --- /dev/null +++ b/config.php @@ -0,0 +1,30 @@ +requireAuth(); // This will redirect to login if not authenticated + // Load and parse JSON if (!file_exists($responseFile)) { die("Error: Response file not found. Please run webhook_caller.php first."); diff --git a/webhook_caller.php b/webhook_caller.php index c5ca2b2..79d9a3a 100644 --- a/webhook_caller.php +++ b/webhook_caller.php @@ -1,4 +1,9 @@ requireAuth(); // This will redirect to login if not authenticated + // Configuration $webhookUrl = 'https://hook.us1.make.celonis.com/u8i4yq6rydu8u8g9bfhk0xbajsyckrmj'; $responseFile = 'webhook_response.json';