diff --git a/client/src/contexts/MultiTenantAuthContext.jsx b/client/src/contexts/MultiTenantAuthContext.jsx new file mode 100644 index 0000000..0ff10b5 --- /dev/null +++ b/client/src/contexts/MultiTenantAuthContext.jsx @@ -0,0 +1,255 @@ +/** + * Multi-Tenant Authentication Context + * Handles authentication for different tenants and providers + */ + +import React, { createContext, useContext, useReducer, useEffect } from 'react'; +import api from '../services/api'; + +const MultiTenantAuthContext = createContext(); + +const authReducer = (state, action) => { + switch (action.type) { + case 'SET_LOADING': + return { ...state, loading: action.payload }; + case 'SET_TENANT': + return { ...state, tenant: action.payload }; + case 'SET_AUTH_CONFIG': + return { ...state, authConfig: action.payload }; + case 'LOGIN_START': + return { ...state, loading: true, error: null }; + case 'LOGIN_SUCCESS': + return { + ...state, + user: action.payload.user, + token: action.payload.token, + tenant: action.payload.tenant, + isAuthenticated: true, + loading: false, + error: null + }; + case 'LOGIN_FAILURE': + return { ...state, loading: false, error: action.payload, isAuthenticated: false }; + case 'LOGOUT': + return { ...state, user: null, token: null, isAuthenticated: false, loading: false }; + case 'SET_ERROR': + return { ...state, error: action.payload, loading: false }; + default: + return state; + } +}; + +const initialState = { + user: null, + token: localStorage.getItem('token'), + tenant: null, + authConfig: null, + isAuthenticated: false, + loading: true, + error: null +}; + +export const MultiTenantAuthProvider = ({ children }) => { + const [state, dispatch] = useReducer(authReducer, initialState); + + // Determine tenant from URL or storage + const determineTenant = () => { + // Method 1: Subdomain + const hostname = window.location.hostname; + const subdomain = hostname.split('.')[0]; + if (subdomain && subdomain !== 'www' && subdomain !== 'localhost' && !hostname.includes('127.0.0.1')) { + return subdomain; + } + + // Method 2: URL parameter + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('tenant')) { + return urlParams.get('tenant'); + } + + // Method 3: Local storage + const storedTenant = localStorage.getItem('tenant'); + if (storedTenant) { + return storedTenant; + } + + // Default tenant + return 'default'; + }; + + // Initialize authentication + useEffect(() => { + const initAuth = async () => { + try { + const tenantId = determineTenant(); + dispatch({ type: 'SET_TENANT', payload: tenantId }); + localStorage.setItem('tenant', tenantId); + + // Get tenant auth configuration + const authConfigResponse = await api.get(`/auth/config/${tenantId}`); + dispatch({ type: 'SET_AUTH_CONFIG', payload: authConfigResponse.data.data }); + + // Check if user is already authenticated + const token = localStorage.getItem('token'); + if (token) { + // Validate token with current tenant context + const profileResponse = await api.get('/users/profile'); + dispatch({ + type: 'LOGIN_SUCCESS', + payload: { + user: profileResponse.data.data, + token: token, + tenant: tenantId + } + }); + } else { + dispatch({ type: 'SET_LOADING', payload: false }); + } + } catch (error) { + console.error('Auth initialization error:', error); + localStorage.removeItem('token'); + dispatch({ type: 'SET_LOADING', payload: false }); + } + }; + + initAuth(); + }, []); + + // Handle URL token (from SSO redirects) + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const urlToken = urlParams.get('token'); + const urlTenant = urlParams.get('tenant'); + + if (urlToken && urlTenant) { + localStorage.setItem('token', urlToken); + localStorage.setItem('tenant', urlTenant); + + // Clean URL + window.history.replaceState({}, document.title, window.location.pathname); + + // Reload to reinitialize with new token + window.location.reload(); + } + }, []); + + // Login function + const login = async (credentials) => { + try { + dispatch({ type: 'LOGIN_START' }); + + const tenantId = state.tenant || determineTenant(); + + // For local authentication, use existing login endpoint + if (state.authConfig?.provider === 'local' || state.authConfig?.provider === 'ldap') { + const response = await api.post('/auth/login', { + ...credentials, + tenant: tenantId + }); + + const { user, token } = response.data.data; + localStorage.setItem('token', token); + localStorage.setItem('tenant', tenantId); + + dispatch({ + type: 'LOGIN_SUCCESS', + payload: { user, token, tenant: tenantId } + }); + + return { success: true }; + } else { + // For SSO providers, redirect to SSO endpoint + const ssoUrl = getSSOLoginUrl(state.authConfig, tenantId); + window.location.href = ssoUrl; + return { success: true, redirect: true }; + } + } catch (error) { + const errorMessage = error.response?.data?.message || 'Login failed'; + dispatch({ type: 'LOGIN_FAILURE', payload: errorMessage }); + return { success: false, error: errorMessage }; + } + }; + + // SSO Login + const loginWithSSO = () => { + const tenantId = state.tenant || determineTenant(); + const ssoUrl = getSSOLoginUrl(state.authConfig, tenantId); + window.location.href = ssoUrl; + }; + + // Get SSO login URL + const getSSOLoginUrl = (authConfig, tenantId) => { + const returnUrl = encodeURIComponent(window.location.pathname + window.location.search); + + switch (authConfig?.provider) { + case 'saml': + return `/auth/saml/${tenantId}/login?returnUrl=${returnUrl}`; + case 'oauth': + return `/auth/oauth/${tenantId}/login?returnUrl=${returnUrl}`; + default: + return `/login?tenant=${tenantId}`; + } + }; + + // Logout + const logout = async () => { + try { + const tenantId = state.tenant; + + // Call logout endpoint + const response = await api.post('/auth/logout'); + + // Clear local storage + localStorage.removeItem('token'); + localStorage.removeItem('tenant'); + + dispatch({ type: 'LOGOUT' }); + + // Handle provider-specific logout + if (response.data.logout_url) { + window.location.href = response.data.logout_url; + } else { + window.location.href = `/login?tenant=${tenantId}`; + } + } catch (error) { + console.error('Logout error:', error); + // Force logout even if API call fails + localStorage.removeItem('token'); + localStorage.removeItem('tenant'); + dispatch({ type: 'LOGOUT' }); + window.location.href = '/login'; + } + }; + + // Switch tenant + const switchTenant = (newTenantId) => { + localStorage.setItem('tenant', newTenantId); + localStorage.removeItem('token'); // Clear token when switching tenants + window.location.href = `/login?tenant=${newTenantId}`; + }; + + const value = { + ...state, + login, + logout, + loginWithSSO, + switchTenant, + getSSOLoginUrl + }; + + return ( + + {children} + + ); +}; + +export const useMultiTenantAuth = () => { + const context = useContext(MultiTenantAuthContext); + if (!context) { + throw new Error('useMultiTenantAuth must be used within a MultiTenantAuthProvider'); + } + return context; +}; + +export default MultiTenantAuthContext; diff --git a/client/src/pages/MultiTenantLogin.jsx b/client/src/pages/MultiTenantLogin.jsx new file mode 100644 index 0000000..03f45a0 --- /dev/null +++ b/client/src/pages/MultiTenantLogin.jsx @@ -0,0 +1,305 @@ +/** + * Enhanced Login Component with Multi-Tenant Support + * Handles different authentication providers based on tenant configuration + */ + +import React, { useState, useEffect } from 'react'; +import { Navigate, useSearchParams } from 'react-router-dom'; +import { useMultiTenantAuth } from '../contexts/MultiTenantAuthContext'; +import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'; +import toast from 'react-hot-toast'; + +const MultiTenantLogin = () => { + const [searchParams] = useSearchParams(); + const [credentials, setCredentials] = useState({ + username: '', + password: '' + }); + const [showPassword, setShowPassword] = useState(false); + const { + login, + loginWithSSO, + loading, + isAuthenticated, + tenant, + authConfig, + error + } = useMultiTenantAuth(); + + const tenantParam = searchParams.get('tenant'); + const errorParam = searchParams.get('error'); + + useEffect(() => { + // Handle SSO errors from URL parameters + if (errorParam) { + const errorMessages = { + 'auth_failed': 'Authentication failed. Please try again.', + 'auth_cancelled': 'Authentication was cancelled.', + 'user_creation_failed': 'Failed to create user account.', + 'saml_error': 'SAML authentication error.', + 'oauth_error': 'OAuth authentication error.' + }; + + toast.error(errorMessages[errorParam] || 'Authentication error occurred.'); + } + }, [errorParam]); + + if (isAuthenticated) { + return ; + } + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!credentials.username || !credentials.password) { + toast.error('Please fill in all fields'); + return; + } + + const result = await login(credentials); + + if (result.success && !result.redirect) { + toast.success('Login successful!'); + } else if (!result.success) { + toast.error(result.error || 'Login failed'); + } + // If redirect is true, the page will redirect to SSO provider + }; + + const handleChange = (e) => { + setCredentials({ + ...credentials, + [e.target.name]: e.target.value + }); + }; + + const handleSSOLogin = () => { + loginWithSSO(); + }; + + const renderAuthenticationMethod = () => { + if (!authConfig) { + return ( +
+
+

Loading authentication configuration...

+
+ ); + } + + const { provider, features } = authConfig; + + // SSO-only providers + if (provider === 'saml' || provider === 'oauth') { + return ( +
+
+

+ Single Sign-On Required +

+

+ Please sign in using your organization's identity provider. +

+
+ + + + {tenant && tenant !== 'default' && ( +
+

+ Organization: {tenant} +

+
+ )} +
+ ); + } + + // Local or LDAP authentication with form + return ( +
+
+
+ + +
+
+ + + +
+
+ +
+ +
+ + {/* Show additional SSO option if available */} + {features?.sso_login && ( +
+
+
+
+
+
+ Or +
+
+ + +
+ )} + + {/* Demo credentials for local auth */} + {provider === 'local' && ( +
+

+ Demo credentials:
+ Username: admin
+ Password: admin123 +

+
+ )} + + {/* LDAP info */} + {provider === 'ldap' && ( +
+

+ Use your Active Directory credentials +

+
+ )} + + ); + }; + + return ( +
+
+
+
+ + + +
+

+ Drone Detection System +

+

+ Sign in to your account +

+ + {/* Tenant indicator */} + {tenant && tenant !== 'default' && ( +
+ + Organization: {tenant} + +
+ )} +
+ + {error && ( +
+
{error}
+
+ )} + + {renderAuthenticationMethod()} + + {/* Tenant switcher for development */} + {process.env.NODE_ENV === 'development' && ( +
+
+ Development Options +
+ + + +
+
+
+ )} +
+
+ ); +}; + +export default MultiTenantLogin; diff --git a/docs/MULTI_TENANT_AUTH_SETUP.md b/docs/MULTI_TENANT_AUTH_SETUP.md new file mode 100644 index 0000000..ad4855e --- /dev/null +++ b/docs/MULTI_TENANT_AUTH_SETUP.md @@ -0,0 +1,291 @@ +# Multi-Tenant Authentication Setup Guide + +## Overview + +This guide explains how to configure your UAV Detection System for multi-tenant authentication with support for various identity providers including Active Directory, SAML, OAuth, and LDAP. + +## ๐Ÿ—๏ธ Architecture + +### Tenant Isolation +- **Subdomain-based**: `customer1.yourapp.com`, `customer2.yourapp.com` +- **Header-based**: `X-Tenant-ID: customer1` +- **JWT-based**: Token contains tenant information + +### Supported Authentication Providers +1. **Local JWT** (default) +2. **SAML 2.0** (Active Directory/ADFS) +3. **OAuth 2.0/OpenID Connect** (Azure AD, Google, etc.) +4. **LDAP** (Direct Active Directory) +5. **Custom SSO** + +## ๐Ÿ“ฆ Required Dependencies + +Add these to your `package.json`: + +```json +{ + "dependencies": { + "passport": "^0.6.0", + "passport-saml": "^3.2.4", + "passport-oauth2": "^1.7.0", + "passport-openidconnect": "^0.1.1", + "ldapjs": "^3.0.3", + "express-session": "^1.17.3" + } +} +``` + +## ๐Ÿ› ๏ธ Installation + +### 1. Install Dependencies +```bash +npm install passport passport-saml passport-oauth2 passport-openidconnect ldapjs express-session +``` + +### 2. Database Migration +```bash +# Create tenant table and update user table +npm run migrate +``` + +### 3. Environment Variables +```env +# Multi-tenant configuration +BASE_URL=https://yourapp.com +DOMAIN_NAME=yourapp.com +SESSION_SECRET=your-session-secret-key + +# Default tenant (backward compatibility) +DEFAULT_TENANT_ID=default +``` + +### 4. Update Server Configuration + +In `server/index.js`, add the auth routes: + +```javascript +// Add multi-tenant auth routes +app.use('/auth', require('./routes/auth')); +app.use('/api/tenants', require('./routes/tenants')); + +// Update existing auth middleware +const MultiTenantAuth = require('./middleware/multi-tenant-auth'); +const multiAuth = new MultiTenantAuth(); + +// Replace existing authenticateToken with multi-tenant version +app.use('/api', (req, res, next) => { + multiAuth.authenticate(req, res, next); +}); +``` + +## ๐Ÿ”ง Configuration Examples + +### 1. Active Directory SAML Integration + +```javascript +// Tenant configuration for SAML +const tenantConfig = { + name: "Acme Corporation", + slug: "acme", + auth_provider: "saml", + auth_config: { + sso_url: "https://adfs.acme.com/adfs/ls/", + certificate: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", + issuer: "urn:acme:uav-detection", + logout_url: "https://adfs.acme.com/adfs/ls/?wa=wsignout1.0" + }, + role_mapping: { + "Domain Admins": "admin", + "UAV-Operators": "operator", + "UAV-Viewers": "viewer", + "default": "viewer" + } +}; +``` + +### 2. Azure AD OAuth Integration + +```javascript +// Tenant configuration for OAuth (Azure AD) +const tenantConfig = { + name: "TechCorp", + slug: "techcorp", + auth_provider: "oauth", + auth_config: { + client_id: "your-azure-app-id", + client_secret: "your-azure-app-secret", + authorization_url: "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize", + token_url: "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token", + userinfo_url: "https://graph.microsoft.com/v1.0/me", + scopes: ["openid", "profile", "email", "User.Read"] + }, + user_mapping: { + username: ["preferred_username", "userPrincipalName"], + email: ["mail", "email"], + firstName: ["givenName"], + lastName: ["surname"] + } +}; +``` + +### 3. Direct LDAP Integration + +```javascript +// Tenant configuration for LDAP +const tenantConfig = { + name: "Enterprise Corp", + slug: "enterprise", + auth_provider: "ldap", + auth_config: { + url: "ldaps://dc.enterprise.com:636", + base_dn: "dc=enterprise,dc=com", + bind_dn: "cn=service-account,ou=service-accounts,dc=enterprise,dc=com", + bind_password: "service-account-password", + user_search_filter: "(sAMAccountName={username})", + domain: "ENTERPRISE" + } +}; +``` + +## ๐Ÿš€ API Usage + +### Create Tenant +```javascript +POST /api/tenants +{ + "name": "Customer Company", + "slug": "customer", + "auth_provider": "saml", + "auth_config": { ... }, + "subscription_type": "premium" +} +``` + +### Login Flow +```javascript +// 1. Get tenant auth config +GET /auth/config/customer + +// 2. For SSO providers, redirect to: +GET /auth/saml/customer/login +GET /auth/oauth/customer/login + +// 3. For local/LDAP, use: +POST /auth/login +{ + "username": "user@customer.com", + "password": "password" +} +``` + +### Frontend Integration +```javascript +// Determine authentication method +const authConfig = await fetch(`/auth/config/${tenantId}`); + +if (authConfig.provider === 'saml') { + // Redirect to SAML login + window.location.href = `/auth/saml/${tenantId}/login`; +} else { + // Show login form for local/LDAP + showLoginForm(); +} +``` + +## ๐Ÿ” Security Considerations + +### 1. Certificate Management +- Store SAML certificates securely +- Rotate certificates regularly +- Validate certificate chains + +### 2. Tenant Isolation +- Ensure users can only access their tenant's data +- Validate tenant context in all API calls +- Implement tenant-specific rate limiting + +### 3. Configuration Security +- Encrypt sensitive configuration data +- Use environment variables for secrets +- Implement configuration validation + +## ๐Ÿ“‹ Deployment Scenarios + +### SaaS Deployment +```nginx +# Nginx configuration for subdomain routing +server { + server_name *.yourapp.com; + + location / { + proxy_pass http://app-server; + proxy_set_header Host $host; + proxy_set_header X-Tenant-ID $subdomain; + } +} +``` + +### On-Premise Deployment +```yaml +# Docker Compose for on-premise +version: '3.8' +services: + app: + environment: + - DEFAULT_TENANT_ID=onprem + - AUTH_PROVIDER=ldap + - LDAP_URL=ldap://company-dc:389 +``` + +## ๐Ÿงช Testing + +### Test Authentication Configuration +```bash +# Test LDAP connection +curl -X POST /auth/test/customer \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer admin-token" + +# Test SAML metadata +curl /auth/saml/customer/metadata +``` + +### User Creation Flow +1. User authenticates with external provider +2. System maps external attributes to internal user +3. User record created/updated with tenant association +4. JWT issued with tenant context + +## ๐Ÿ”„ Migration Strategy + +### Existing Customers +1. Create default tenant for existing users +2. Gradually migrate to tenant-specific authentication +3. Maintain backward compatibility during transition + +### Data Migration +```sql +-- Create default tenant +INSERT INTO tenants (name, slug, auth_provider) +VALUES ('Default', 'default', 'local'); + +-- Associate existing users with default tenant +UPDATE users SET tenant_id = ( + SELECT id FROM tenants WHERE slug = 'default' +) WHERE tenant_id IS NULL; +``` + +## ๐Ÿ“ž Support + +### Common Issues +1. **SAML Certificate Errors**: Verify certificate format and validity +2. **LDAP Connection Failures**: Check network connectivity and credentials +3. **OAuth Scope Issues**: Ensure required scopes are granted +4. **Tenant Not Found**: Verify tenant slug and domain configuration + +### Monitoring +- Log authentication attempts and failures +- Monitor tenant-specific metrics +- Alert on authentication service health + +This architecture provides a scalable foundation for both SaaS and on-premise deployments while maintaining security and user experience. diff --git a/server/config/auth-providers.js b/server/config/auth-providers.js new file mode 100644 index 0000000..1896659 --- /dev/null +++ b/server/config/auth-providers.js @@ -0,0 +1,113 @@ +/** + * Authentication Providers Configuration + * Supports multiple auth strategies for SaaS and on-premise deployments + */ + +const AuthProviders = { + // Local JWT authentication (default) + LOCAL: 'local', + + // SAML 2.0 for Active Directory/ADFS + SAML: 'saml', + + // OAuth 2.0/OpenID Connect + OAUTH: 'oauth', + + // LDAP for on-premise AD + LDAP: 'ldap', + + // Custom SSO + CUSTOM_SSO: 'custom_sso' +}; + +/** + * Tenant-specific authentication configuration + * Each tenant can have different auth providers + */ +class AuthConfig { + constructor() { + this.providers = new Map(); + this.defaultProvider = AuthProviders.LOCAL; + } + + /** + * Register authentication provider for a tenant + * @param {string} tenantId - Tenant identifier + * @param {object} config - Provider configuration + */ + registerProvider(tenantId, config) { + this.providers.set(tenantId, { + type: config.type, + enabled: config.enabled || true, + config: config.settings, + userMapping: config.userMapping || this.getDefaultUserMapping(), + roleMapping: config.roleMapping || this.getDefaultRoleMapping(), + createdAt: new Date() + }); + } + + /** + * Get authentication provider for tenant + * @param {string} tenantId - Tenant identifier + * @returns {object} Provider configuration + */ + getProvider(tenantId) { + return this.providers.get(tenantId) || { + type: this.defaultProvider, + enabled: true, + config: {}, + userMapping: this.getDefaultUserMapping(), + roleMapping: this.getDefaultRoleMapping() + }; + } + + /** + * Default user attribute mapping from external providers + */ + getDefaultUserMapping() { + return { + username: ['preferred_username', 'samAccountName', 'username', 'sub'], + email: ['email', 'mail', 'emailAddress'], + firstName: ['given_name', 'givenName', 'firstName'], + lastName: ['family_name', 'surname', 'lastName'], + displayName: ['name', 'displayName', 'cn'], + phoneNumber: ['phone_number', 'telephoneNumber', 'mobile'] + }; + } + + /** + * Default role mapping from external providers to internal roles + */ + getDefaultRoleMapping() { + return { + // Active Directory groups to internal roles + 'Domain Admins': 'admin', + 'UAV-Admins': 'admin', + 'UAV-Operators': 'operator', + 'UAV-Viewers': 'viewer', + + // OAuth/SAML role claims + 'admin': 'admin', + 'operator': 'operator', + 'viewer': 'viewer', + + // Default fallback + 'default': 'viewer' + }; + } + + /** + * Get all configured providers + */ + getAllProviders() { + return Array.from(this.providers.entries()).map(([tenantId, config]) => ({ + tenantId, + ...config + })); + } +} + +module.exports = { + AuthProviders, + AuthConfig +}; diff --git a/server/middleware/ldap-auth.js b/server/middleware/ldap-auth.js new file mode 100644 index 0000000..4f360a2 --- /dev/null +++ b/server/middleware/ldap-auth.js @@ -0,0 +1,325 @@ +/** + * LDAP Authentication Provider + * Direct integration with Active Directory LDAP + */ + +const ldap = require('ldapjs'); +const crypto = require('crypto'); + +class LDAPAuth { + constructor() { + this.connections = new Map(); + } + + /** + * Create LDAP client connection + */ + createLDAPClient(config) { + const clientOptions = { + url: config.url, // ldap://dc.company.com:389 or ldaps://dc.company.com:636 + timeout: config.timeout || 5000, + connectTimeout: config.connect_timeout || 10000, + tlsOptions: { + rejectUnauthorized: config.tls_reject_unauthorized !== false + } + }; + + if (config.ca_cert) { + clientOptions.tlsOptions.ca = [config.ca_cert]; + } + + return ldap.createClient(clientOptions); + } + + /** + * Authenticate user against LDAP + */ + async authenticateUser(tenantId, username, password, config) { + return new Promise((resolve, reject) => { + const client = this.createLDAPClient(config); + + // Build user DN + const userDN = this.buildUserDN(username, config); + + client.bind(userDN, password, (err) => { + if (err) { + client.unbind(); + return reject(new Error('Invalid credentials')); + } + + // Search for user details + this.searchUser(client, username, config) + .then(userInfo => { + client.unbind(); + resolve(userInfo); + }) + .catch(searchErr => { + client.unbind(); + reject(searchErr); + }); + }); + }); + } + + /** + * Build user Distinguished Name (DN) + */ + buildUserDN(username, config) { + // Method 1: UPN format (user@domain.com) + if (config.user_dn_format === 'upn') { + return `${username}@${config.domain}`; + } + + // Method 2: CN format (CN=username,OU=Users,DC=domain,DC=com) + if (config.user_dn_template) { + return config.user_dn_template.replace('{username}', username); + } + + // Method 3: sAMAccountName format (DOMAIN\\username) + if (config.domain) { + return `${config.domain}\\${username}`; + } + + // Default: assume username is already a DN + return username; + } + + /** + * Search for user information in LDAP + */ + async searchUser(client, username, config) { + return new Promise((resolve, reject) => { + const baseDN = config.base_dn || 'dc=example,dc=com'; + const searchFilter = config.user_search_filter || `(sAMAccountName=${username})`; + + const searchOptions = { + filter: searchFilter.replace('{username}', username), + scope: 'sub', + attributes: [ + 'sAMAccountName', + 'userPrincipalName', + 'mail', + 'givenName', + 'sn', + 'displayName', + 'telephoneNumber', + 'memberOf', + 'objectClass', + 'distinguishedName' + ] + }; + + client.search(baseDN, searchOptions, (err, searchRes) => { + if (err) { + return reject(err); + } + + let userEntry = null; + + searchRes.on('searchEntry', (entry) => { + userEntry = entry.object; + }); + + searchRes.on('error', (err) => { + reject(err); + }); + + searchRes.on('end', (result) => { + if (!userEntry) { + return reject(new Error('User not found in directory')); + } + + // Extract user information + const userInfo = { + id: userEntry.distinguishedName, + username: userEntry.sAMAccountName || userEntry.userPrincipalName, + email: userEntry.mail, + firstName: userEntry.givenName, + lastName: userEntry.sn, + displayName: userEntry.displayName, + phoneNumber: userEntry.telephoneNumber, + groups: this.extractGroups(userEntry.memberOf), + distinguishedName: userEntry.distinguishedName, + raw: userEntry + }; + + resolve(userInfo); + }); + }); + }); + } + + /** + * Extract group names from memberOf attribute + */ + extractGroups(memberOf) { + if (!memberOf) { + return []; + } + + const groups = Array.isArray(memberOf) ? memberOf : [memberOf]; + + return groups.map(dn => { + // Extract CN from DN (e.g., "CN=Domain Admins,CN=Users,DC=domain,DC=com" -> "Domain Admins") + const cnMatch = dn.match(/^CN=([^,]+)/i); + return cnMatch ? cnMatch[1] : dn; + }); + } + + /** + * Test LDAP connection + */ + async testConnection(config) { + return new Promise((resolve, reject) => { + const client = this.createLDAPClient(config); + + const testDN = config.bind_dn || config.admin_dn; + const testPassword = config.bind_password || config.admin_password; + + if (!testDN || !testPassword) { + client.unbind(); + return reject(new Error('Admin credentials required for connection test')); + } + + client.bind(testDN, testPassword, (err) => { + client.unbind(); + + if (err) { + reject(new Error(`LDAP connection failed: ${err.message}`)); + } else { + resolve(true); + } + }); + }); + } + + /** + * Handle LDAP authentication request + */ + async authenticate(req, res, next) { + // LDAP authentication is typically used for login forms + // This middleware would be used in login POST endpoint + + if (req.method !== 'POST' || !req.body.username || !req.body.password) { + return res.status(400).json({ + success: false, + message: 'Username and password required for LDAP authentication' + }); + } + + try { + const tenantId = req.tenant.id; + const config = req.tenant.authConfig.config; + const { username, password } = req.body; + + // Authenticate against LDAP + const userInfo = await this.authenticateUser(tenantId, username, password, config); + + const { MultiTenantAuth } = require('./multi-tenant-auth'); + const multiAuth = new MultiTenantAuth(); + + // Create or update user + const user = await multiAuth.createOrUpdateExternalUser( + tenantId, + userInfo, + req.tenant.authConfig + ); + + // Generate JWT token + const token = multiAuth.generateJWTToken(user, tenantId); + + res.json({ + success: true, + data: { + user: { + id: user.id, + username: user.username, + email: user.email, + role: user.role, + first_name: user.first_name, + last_name: user.last_name + }, + token, + expires_in: '24h' + }, + message: 'LDAP authentication successful' + }); + + } catch (error) { + console.error('LDAP authentication error:', error); + + res.status(401).json({ + success: false, + message: 'LDAP authentication failed', + error: process.env.NODE_ENV === 'development' ? error.message : 'Invalid credentials' + }); + } + } + + /** + * Sync users from LDAP directory + */ + async syncUsers(tenantId, config) { + return new Promise((resolve, reject) => { + const client = this.createLDAPClient(config); + + // Bind with admin credentials + const adminDN = config.bind_dn || config.admin_dn; + const adminPassword = config.bind_password || config.admin_password; + + client.bind(adminDN, adminPassword, (err) => { + if (err) { + client.unbind(); + return reject(new Error('Failed to bind with admin credentials')); + } + + const baseDN = config.base_dn; + const userFilter = config.user_sync_filter || '(objectClass=user)'; + + const searchOptions = { + filter: userFilter, + scope: 'sub', + attributes: [ + 'sAMAccountName', 'userPrincipalName', 'mail', 'givenName', + 'sn', 'displayName', 'telephoneNumber', 'memberOf', 'distinguishedName' + ] + }; + + const users = []; + + client.search(baseDN, searchOptions, (err, searchRes) => { + if (err) { + client.unbind(); + return reject(err); + } + + searchRes.on('searchEntry', (entry) => { + const userEntry = entry.object; + users.push({ + id: userEntry.distinguishedName, + username: userEntry.sAMAccountName, + email: userEntry.mail, + firstName: userEntry.givenName, + lastName: userEntry.sn, + displayName: userEntry.displayName, + phoneNumber: userEntry.telephoneNumber, + groups: this.extractGroups(userEntry.memberOf) + }); + }); + + searchRes.on('error', (err) => { + client.unbind(); + reject(err); + }); + + searchRes.on('end', () => { + client.unbind(); + resolve(users); + }); + }); + }); + }); + } +} + +module.exports = LDAPAuth; diff --git a/server/middleware/multi-tenant-auth.js b/server/middleware/multi-tenant-auth.js new file mode 100644 index 0000000..0d55cec --- /dev/null +++ b/server/middleware/multi-tenant-auth.js @@ -0,0 +1,284 @@ +/** + * Multi-Tenant Authentication Middleware + * Routes authentication requests to appropriate providers + */ + +const jwt = require('jsonwebtoken'); +const { User, Tenant } = require('../models'); +const { AuthConfig, AuthProviders } = require('../config/auth-providers'); +const SAMLAuth = require('./saml-auth'); +const OAuthAuth = require('./oauth-auth'); +const LDAPAuth = require('./ldap-auth'); + +class MultiTenantAuth { + constructor() { + this.authConfig = new AuthConfig(); + this.providers = new Map(); + + // Initialize authentication providers + this.initializeProviders(); + } + + /** + * Initialize all authentication providers + */ + initializeProviders() { + this.providers.set(AuthProviders.SAML, new SAMLAuth()); + this.providers.set(AuthProviders.OAUTH, new OAuthAuth()); + this.providers.set(AuthProviders.LDAP, new LDAPAuth()); + } + + /** + * Determine tenant from request + * Can be from subdomain, header, or JWT + */ + async determineTenant(req) { + // Method 1: Subdomain (tenant.yourapp.com) + const subdomain = req.hostname.split('.')[0]; + if (subdomain && subdomain !== 'www' && subdomain !== 'api') { + return subdomain; + } + + // Method 2: Custom header + const tenantHeader = req.headers['x-tenant-id']; + if (tenantHeader) { + return tenantHeader; + } + + // Method 3: From JWT token (for existing sessions) + const token = req.headers.authorization?.split(' ')[1]; + if (token) { + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + return decoded.tenantId; + } catch (error) { + // Token invalid, continue with other methods + } + } + + // Method 4: Query parameter (for redirects) + if (req.query.tenant) { + return req.query.tenant; + } + + // Default to 'default' tenant for backward compatibility + return 'default'; + } + + /** + * Get authentication configuration for tenant + */ + async getTenantAuthConfig(tenantId) { + const tenant = await Tenant.findOne({ where: { slug: tenantId } }); + if (!tenant) { + // Return default local auth for unknown tenants + return { + type: AuthProviders.LOCAL, + enabled: true, + config: {}, + userMapping: this.authConfig.getDefaultUserMapping(), + roleMapping: this.authConfig.getDefaultRoleMapping() + }; + } + + return this.authConfig.getProvider(tenantId); + } + + /** + * Authenticate user based on tenant configuration + */ + async authenticate(req, res, next) { + try { + const tenantId = await this.determineTenant(req); + const authConfig = await this.getTenantAuthConfig(tenantId); + + // Attach tenant info to request + req.tenant = { id: tenantId, authConfig }; + + // Route to appropriate authentication provider + switch (authConfig.type) { + case AuthProviders.LOCAL: + return this.authenticateLocal(req, res, next); + + case AuthProviders.SAML: + return this.authenticateSAML(req, res, next); + + case AuthProviders.OAUTH: + return this.authenticateOAuth(req, res, next); + + case AuthProviders.LDAP: + return this.authenticateLDAP(req, res, next); + + default: + return this.authenticateLocal(req, res, next); + } + } catch (error) { + console.error('Multi-tenant auth error:', error); + return res.status(500).json({ + success: false, + message: 'Authentication service error' + }); + } + } + + /** + * Local JWT authentication (existing system) + */ + async authenticateLocal(req, res, next) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ + success: false, + message: 'Access token required' + }); + } + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const user = await User.findByPk(decoded.userId, { + attributes: ['id', 'username', 'email', 'role', 'is_active', 'tenant_id'] + }); + + if (!user || !user.is_active) { + return res.status(401).json({ + success: false, + message: 'Invalid or inactive user' + }); + } + + // Verify user belongs to tenant + if (user.tenant_id !== req.tenant.id) { + return res.status(403).json({ + success: false, + message: 'User not authorized for this tenant' + }); + } + + req.user = user; + next(); + } catch (error) { + console.error('JWT verification error:', error); + return res.status(403).json({ + success: false, + message: 'Invalid or expired token' + }); + } + } + + /** + * SAML authentication for Active Directory + */ + async authenticateSAML(req, res, next) { + const provider = this.providers.get(AuthProviders.SAML); + return provider.authenticate(req, res, next); + } + + /** + * OAuth 2.0/OpenID Connect authentication + */ + async authenticateOAuth(req, res, next) { + const provider = this.providers.get(AuthProviders.OAUTH); + return provider.authenticate(req, res, next); + } + + /** + * LDAP authentication + */ + async authenticateLDAP(req, res, next) { + const provider = this.providers.get(AuthProviders.LDAP); + return provider.authenticate(req, res, next); + } + + /** + * Create or update user from external provider + */ + async createOrUpdateExternalUser(tenantId, externalUser, authConfig) { + const userMapping = authConfig.userMapping; + const roleMapping = authConfig.roleMapping; + + // Map external user attributes to internal user fields + const userData = { + username: this.getAttributeValue(externalUser, userMapping.username), + email: this.getAttributeValue(externalUser, userMapping.email), + first_name: this.getAttributeValue(externalUser, userMapping.firstName), + last_name: this.getAttributeValue(externalUser, userMapping.lastName), + phone_number: this.getAttributeValue(externalUser, userMapping.phoneNumber), + tenant_id: tenantId, + is_active: true, + external_provider: authConfig.type, + external_id: externalUser.id || externalUser.sub || externalUser.nameID, + last_login: new Date() + }; + + // Map roles from external provider + const externalRoles = externalUser.roles || externalUser.groups || []; + userData.role = this.mapExternalRole(externalRoles, roleMapping); + + // Find or create user + const [user, created] = await User.findOrCreate({ + where: { + external_id: userData.external_id, + tenant_id: tenantId + }, + defaults: userData + }); + + // Update existing user + if (!created) { + await user.update(userData); + } + + return user; + } + + /** + * Get attribute value from external user object + */ + getAttributeValue(externalUser, attributeNames) { + if (!Array.isArray(attributeNames)) { + attributeNames = [attributeNames]; + } + + for (const attrName of attributeNames) { + if (externalUser[attrName]) { + return externalUser[attrName]; + } + } + + return null; + } + + /** + * Map external roles to internal roles + */ + mapExternalRole(externalRoles, roleMapping) { + for (const externalRole of externalRoles) { + if (roleMapping[externalRole]) { + return roleMapping[externalRole]; + } + } + + return roleMapping.default || 'viewer'; + } + + /** + * Generate JWT token for authenticated user + */ + generateJWTToken(user, tenantId) { + return jwt.sign( + { + userId: user.id, + username: user.username, + role: user.role, + tenantId: tenantId, + provider: user.external_provider || 'local' + }, + process.env.JWT_SECRET, + { expiresIn: '24h' } + ); + } +} + +module.exports = MultiTenantAuth; diff --git a/server/middleware/oauth-auth.js b/server/middleware/oauth-auth.js new file mode 100644 index 0000000..0a8fa79 --- /dev/null +++ b/server/middleware/oauth-auth.js @@ -0,0 +1,245 @@ +/** + * OAuth 2.0/OpenID Connect Authentication Provider + * Supports modern identity providers like Azure AD, Google, etc. + */ + +const passport = require('passport'); +const OAuth2Strategy = require('passport-oauth2'); +const OpenIDConnectStrategy = require('passport-openidconnect'); + +class OAuthAuth { + constructor() { + this.strategies = new Map(); + } + + /** + * Configure OAuth strategy for a tenant + */ + configureOAuthStrategy(tenantId, config) { + const strategyName = `oauth-${tenantId}`; + + // Determine if this is OpenID Connect or plain OAuth2 + const isOpenIDConnect = config.discovery_url || config.issuer; + + if (isOpenIDConnect) { + return this.configureOpenIDConnectStrategy(tenantId, config); + } else { + return this.configureOAuth2Strategy(tenantId, config); + } + } + + /** + * Configure OpenID Connect strategy (recommended for modern providers) + */ + configureOpenIDConnectStrategy(tenantId, config) { + const strategyName = `oidc-${tenantId}`; + + const oidcOptions = { + issuer: config.issuer || config.discovery_url, + clientID: config.client_id, + clientSecret: config.client_secret, + callbackURL: `${process.env.BASE_URL}/auth/oauth/${tenantId}/callback`, + + // Scopes + scope: config.scopes || ['openid', 'profile', 'email'], + + // Claims + skipUserProfile: false, + + // Additional parameters + customHeaders: config.custom_headers || {}, + passReqToCallback: true + }; + + const strategy = new OpenIDConnectStrategy(oidcOptions, + async (req, issuer, profile, accessToken, refreshToken, done) => { + try { + const externalUser = { + id: profile.id || profile.sub, + username: profile.preferred_username || profile.username || profile.email, + email: profile.email, + firstName: profile.given_name || profile.firstName, + lastName: profile.family_name || profile.lastName, + displayName: profile.name || profile.displayName, + roles: profile.roles || profile['custom:roles'] || [], + groups: profile.groups || profile['custom:groups'] || [], + raw: profile._json // Store raw profile for custom mapping + }; + + return done(null, { tenantId, externalUser, provider: 'oauth' }); + } catch (error) { + return done(error, null); + } + }); + + passport.use(strategyName, strategy); + this.strategies.set(tenantId, { strategy, config, type: 'oidc' }); + + return strategyName; + } + + /** + * Configure OAuth 2.0 strategy (for legacy providers) + */ + configureOAuth2Strategy(tenantId, config) { + const strategyName = `oauth2-${tenantId}`; + + const oauth2Options = { + authorizationURL: config.authorization_url, + tokenURL: config.token_url, + clientID: config.client_id, + clientSecret: config.client_secret, + callbackURL: `${process.env.BASE_URL}/auth/oauth/${tenantId}/callback`, + + // Custom parameters + customHeaders: config.custom_headers || {}, + scope: config.scopes || ['profile', 'email'], + passReqToCallback: true + }; + + const strategy = new OAuth2Strategy(oauth2Options, + async (req, accessToken, refreshToken, profile, done) => { + try { + // Fetch user profile from userinfo endpoint + const userProfile = await this.fetchUserProfile(accessToken, config.userinfo_url); + + const externalUser = { + id: userProfile.sub || userProfile.id, + username: userProfile.preferred_username || userProfile.username || userProfile.email, + email: userProfile.email, + firstName: userProfile.given_name || userProfile.first_name, + lastName: userProfile.family_name || userProfile.last_name, + displayName: userProfile.name || userProfile.display_name, + roles: userProfile.roles || [], + groups: userProfile.groups || [], + raw: userProfile + }; + + return done(null, { tenantId, externalUser, provider: 'oauth' }); + } catch (error) { + return done(error, null); + } + }); + + passport.use(strategyName, strategy); + this.strategies.set(tenantId, { strategy, config, type: 'oauth2' }); + + return strategyName; + } + + /** + * Fetch user profile from OAuth userinfo endpoint + */ + async fetchUserProfile(accessToken, userinfoUrl) { + const axios = require('axios'); + + try { + const response = await axios.get(userinfoUrl, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/json' + } + }); + + return response.data; + } catch (error) { + console.error('Error fetching user profile:', error); + throw new Error('Failed to fetch user profile'); + } + } + + /** + * Initiate OAuth authentication + */ + async authenticate(req, res, next) { + const tenantId = req.tenant.id; + + // Check if strategy is configured for this tenant + if (!this.strategies.has(tenantId)) { + const config = req.tenant.authConfig.config; + this.configureOAuthStrategy(tenantId, config); + } + + const strategyInfo = this.strategies.get(tenantId); + const strategyName = strategyInfo.type === 'oidc' ? `oidc-${tenantId}` : `oauth2-${tenantId}`; + + // Store return URL in session + req.session.returnUrl = req.query.returnUrl || '/'; + + // Use passport to authenticate + passport.authenticate(strategyName, { + state: req.query.returnUrl || '/' + })(req, res, next); + } + + /** + * Handle OAuth callback + */ + async handleCallback(req, res, next) { + const tenantId = req.params.tenantId; + const strategyInfo = this.strategies.get(tenantId); + + if (!strategyInfo) { + return res.redirect(`/login?error=strategy_not_found&tenant=${tenantId}`); + } + + const strategyName = strategyInfo.type === 'oidc' ? `oidc-${tenantId}` : `oauth2-${tenantId}`; + + passport.authenticate(strategyName, async (err, authResult) => { + if (err) { + console.error('OAuth authentication error:', err); + return res.redirect(`/login?error=auth_failed&tenant=${tenantId}`); + } + + if (!authResult) { + return res.redirect(`/login?error=auth_cancelled&tenant=${tenantId}`); + } + + try { + const { MultiTenantAuth } = require('./multi-tenant-auth'); + const multiAuth = new MultiTenantAuth(); + + // Create or update user + const user = await multiAuth.createOrUpdateExternalUser( + tenantId, + authResult.externalUser, + req.tenant.authConfig + ); + + // Generate JWT token + const token = multiAuth.generateJWTToken(user, tenantId); + + // Redirect to application with token + const returnUrl = req.session.returnUrl || req.query.state || '/'; + res.redirect(`${returnUrl}?token=${token}&tenant=${tenantId}`); + + } catch (error) { + console.error('OAuth user creation error:', error); + return res.redirect(`/login?error=user_creation_failed&tenant=${tenantId}`); + } + })(req, res, next); + } + + /** + * Get authorization URL for manual OAuth flow + */ + getAuthorizationUrl(tenantId, returnUrl) { + const strategyInfo = this.strategies.get(tenantId); + if (!strategyInfo) { + throw new Error(`OAuth strategy not configured for tenant: ${tenantId}`); + } + + const config = strategyInfo.config; + const params = new URLSearchParams({ + client_id: config.client_id, + response_type: 'code', + redirect_uri: `${process.env.BASE_URL}/auth/oauth/${tenantId}/callback`, + scope: (config.scopes || ['openid', 'profile', 'email']).join(' '), + state: returnUrl || '/' + }); + + return `${config.authorization_url}?${params.toString()}`; + } +} + +module.exports = OAuthAuth; diff --git a/server/middleware/saml-auth.js b/server/middleware/saml-auth.js new file mode 100644 index 0000000..fb40c6c --- /dev/null +++ b/server/middleware/saml-auth.js @@ -0,0 +1,197 @@ +/** + * SAML Authentication Provider + * Supports Active Directory integration via ADFS + */ + +const passport = require('passport'); +const SamlStrategy = require('passport-saml').Strategy; +const fs = require('fs'); +const path = require('path'); + +class SAMLAuth { + constructor() { + this.strategies = new Map(); + } + + /** + * Configure SAML strategy for a tenant + */ + configureSAMLStrategy(tenantId, config) { + const strategyName = `saml-${tenantId}`; + + const samlOptions = { + // SAML Service Provider (SP) Configuration + callbackUrl: `${process.env.BASE_URL}/auth/saml/${tenantId}/callback`, + entryPoint: config.sso_url, // ADFS SSO URL + issuer: config.issuer || `urn:${process.env.DOMAIN_NAME}:${tenantId}`, + + // Identity Provider (IdP) Configuration + cert: config.certificate, // ADFS signing certificate + + // Optional: Encryption + privateCert: config.private_key, + decryptionPvk: config.private_key, + + // Attribute mapping + attributeConsumingServiceIndex: false, + disableRequestedAuthnContext: true, + + // ADFS specific settings + identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + acceptedClockSkewMs: 5000, + + // Logout + logoutUrl: config.logout_url, + logoutCallbackUrl: `${process.env.BASE_URL}/auth/saml/${tenantId}/logout` + }; + + const strategy = new SamlStrategy(samlOptions, async (profile, done) => { + try { + // Extract user information from SAML assertion + const externalUser = { + id: profile.nameID, + username: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'] || profile.nameID, + email: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || profile.email, + firstName: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'] || profile.givenName, + lastName: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'] || profile.surname, + displayName: profile['http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname'] || profile.displayName, + groups: this.extractGroups(profile), + roles: this.extractRoles(profile) + }; + + return done(null, { tenantId, externalUser, provider: 'saml' }); + } catch (error) { + return done(error, null); + } + }); + + passport.use(strategyName, strategy); + this.strategies.set(tenantId, { strategy, config }); + + return strategyName; + } + + /** + * Extract Active Directory groups from SAML assertion + */ + extractGroups(profile) { + const groupClaims = [ + 'http://schemas.microsoft.com/ws/2008/06/identity/claims/groups', + 'http://schemas.xmlsoap.org/claims/Group', + 'groups' + ]; + + for (const claim of groupClaims) { + if (profile[claim]) { + return Array.isArray(profile[claim]) ? profile[claim] : [profile[claim]]; + } + } + + return []; + } + + /** + * Extract roles from SAML assertion + */ + extractRoles(profile) { + const roleClaims = [ + 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role', + 'http://schemas.xmlsoap.org/claims/Role', + 'roles' + ]; + + for (const claim of roleClaims) { + if (profile[claim]) { + return Array.isArray(profile[claim]) ? profile[claim] : [profile[claim]]; + } + } + + return []; + } + + /** + * Initiate SAML authentication + */ + async authenticate(req, res, next) { + const tenantId = req.tenant.id; + const strategyName = `saml-${tenantId}`; + + // Check if strategy is configured for this tenant + if (!this.strategies.has(tenantId)) { + const config = req.tenant.authConfig.config; + this.configureSAMLStrategy(tenantId, config); + } + + // Use passport to authenticate + passport.authenticate(strategyName, { + additionalParams: { + RelayState: req.query.returnUrl || '/' + } + })(req, res, next); + } + + /** + * Handle SAML callback + */ + async handleCallback(req, res, next) { + const tenantId = req.params.tenantId; + const strategyName = `saml-${tenantId}`; + + passport.authenticate(strategyName, async (err, authResult) => { + if (err) { + console.error('SAML authentication error:', err); + return res.redirect(`/login?error=auth_failed&tenant=${tenantId}`); + } + + if (!authResult) { + return res.redirect(`/login?error=auth_cancelled&tenant=${tenantId}`); + } + + try { + const { MultiTenantAuth } = require('./multi-tenant-auth'); + const multiAuth = new MultiTenantAuth(); + + // Create or update user + const user = await multiAuth.createOrUpdateExternalUser( + tenantId, + authResult.externalUser, + req.tenant.authConfig + ); + + // Generate JWT token + const token = multiAuth.generateJWTToken(user, tenantId); + + // Redirect to application with token + const returnUrl = req.body.RelayState || '/'; + res.redirect(`${returnUrl}?token=${token}&tenant=${tenantId}`); + + } catch (error) { + console.error('SAML user creation error:', error); + return res.redirect(`/login?error=user_creation_failed&tenant=${tenantId}`); + } + })(req, res, next); + } + + /** + * Generate SAML metadata for SP configuration + */ + generateMetadata(tenantId, config) { + const metadata = ` + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + +`; + + return metadata; + } +} + +module.exports = SAMLAuth; diff --git a/server/migrations/20250912000001-add-multi-tenant-support.js b/server/migrations/20250912000001-add-multi-tenant-support.js new file mode 100644 index 0000000..f98d359 --- /dev/null +++ b/server/migrations/20250912000001-add-multi-tenant-support.js @@ -0,0 +1,267 @@ +/** + * Migration: Add Multi-Tenant Support + * Adds tenant table and updates user table for multi-tenancy + */ + +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + // Create tenants table + await queryInterface.createTable('tenants', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true + }, + name: { + type: Sequelize.STRING, + allowNull: false, + comment: 'Human-readable tenant name' + }, + slug: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + comment: 'URL-safe tenant identifier' + }, + domain: { + type: Sequelize.STRING, + allowNull: true, + comment: 'Custom domain for this tenant' + }, + subscription_type: { + type: Sequelize.ENUM('free', 'basic', 'premium', 'enterprise'), + defaultValue: 'basic', + comment: 'Subscription tier' + }, + is_active: { + type: Sequelize.BOOLEAN, + defaultValue: true, + comment: 'Whether tenant is active' + }, + auth_provider: { + type: Sequelize.ENUM('local', 'saml', 'oauth', 'ldap', 'custom_sso'), + defaultValue: 'local', + comment: 'Primary authentication provider' + }, + auth_config: { + type: Sequelize.JSONB, + allowNull: true, + comment: 'Authentication provider configuration' + }, + user_mapping: { + type: Sequelize.JSONB, + allowNull: true, + comment: 'User attribute mapping from external provider' + }, + role_mapping: { + type: Sequelize.JSONB, + allowNull: true, + comment: 'Role mapping from external provider to internal roles' + }, + branding: { + type: Sequelize.JSONB, + allowNull: true, + comment: 'Tenant-specific branding' + }, + features: { + type: Sequelize.JSONB, + defaultValue: { + max_devices: 10, + max_users: 5, + api_rate_limit: 1000, + data_retention_days: 90, + features: ['basic_detection', 'alerts', 'dashboard'] + }, + comment: 'Tenant feature limits and enabled features' + }, + admin_email: { + type: Sequelize.STRING, + allowNull: true, + comment: 'Primary admin email for this tenant' + }, + admin_phone: { + type: Sequelize.STRING, + allowNull: true, + comment: 'Primary admin phone for this tenant' + }, + billing_email: { + type: Sequelize.STRING, + allowNull: true + }, + payment_method_id: { + type: Sequelize.STRING, + allowNull: true, + comment: 'Payment provider customer ID' + }, + metadata: { + type: Sequelize.JSONB, + allowNull: true, + comment: 'Additional tenant metadata' + }, + created_at: { + type: Sequelize.DATE, + defaultValue: Sequelize.NOW + }, + updated_at: { + type: Sequelize.DATE, + defaultValue: Sequelize.NOW + } + }); + + // Add indexes to tenants table + await queryInterface.addIndex('tenants', ['slug'], { unique: true }); + await queryInterface.addIndex('tenants', ['domain'], { + unique: true, + where: { domain: { [Sequelize.Op.ne]: null } } + }); + await queryInterface.addIndex('tenants', ['is_active']); + await queryInterface.addIndex('tenants', ['auth_provider']); + + // Add tenant-related columns to users table + await queryInterface.addColumn('users', 'tenant_id', { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'tenants', + key: 'id' + }, + comment: 'Tenant this user belongs to' + }); + + await queryInterface.addColumn('users', 'external_provider', { + type: Sequelize.ENUM('local', 'saml', 'oauth', 'ldap', 'custom_sso'), + defaultValue: 'local', + comment: 'Authentication provider used for this user' + }); + + await queryInterface.addColumn('users', 'external_id', { + type: Sequelize.STRING, + allowNull: true, + comment: 'User ID from external authentication provider' + }); + + // Add indexes to users table + await queryInterface.addIndex('users', ['tenant_id']); + await queryInterface.addIndex('users', ['external_provider']); + await queryInterface.addIndex('users', ['external_id', 'tenant_id'], { + unique: true, + name: 'users_external_id_tenant_unique', + where: { external_id: { [Sequelize.Op.ne]: null } } + }); + + // Create default tenant for backward compatibility + const defaultTenantId = await queryInterface.bulkInsert('tenants', [{ + id: Sequelize.literal('gen_random_uuid()'), + name: 'Default Organization', + slug: 'default', + subscription_type: 'enterprise', + is_active: true, + auth_provider: 'local', + features: JSON.stringify({ + max_devices: -1, + max_users: -1, + api_rate_limit: 50000, + data_retention_days: -1, + features: ['all'] + }), + created_at: new Date(), + updated_at: new Date() + }], { returning: true }); + + // Associate existing users with default tenant + await queryInterface.sequelize.query(` + UPDATE users + SET tenant_id = (SELECT id FROM tenants WHERE slug = 'default') + WHERE tenant_id IS NULL + `); + + // Add tenant_id to devices table if it exists + try { + await queryInterface.addColumn('devices', 'tenant_id', { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'tenants', + key: 'id' + }, + comment: 'Tenant this device belongs to' + }); + + await queryInterface.addIndex('devices', ['tenant_id']); + + // Associate existing devices with default tenant + await queryInterface.sequelize.query(` + UPDATE devices + SET tenant_id = (SELECT id FROM tenants WHERE slug = 'default') + WHERE tenant_id IS NULL + `); + } catch (error) { + console.log('Devices table not found or already has tenant_id column'); + } + + // Add tenant_id to alert_rules table if it exists + try { + await queryInterface.addColumn('alert_rules', 'tenant_id', { + type: Sequelize.UUID, + allowNull: true, + references: { + model: 'tenants', + key: 'id' + }, + comment: 'Tenant this alert rule belongs to' + }); + + await queryInterface.addIndex('alert_rules', ['tenant_id']); + + // Associate existing alert rules with default tenant + await queryInterface.sequelize.query(` + UPDATE alert_rules + SET tenant_id = (SELECT id FROM tenants WHERE slug = 'default') + WHERE tenant_id IS NULL + `); + } catch (error) { + console.log('Alert_rules table not found or already has tenant_id column'); + } + + console.log('โœ… Multi-tenant support added successfully'); + console.log('โœ… Default tenant created for backward compatibility'); + console.log('โœ… Existing data associated with default tenant'); + }, + + async down(queryInterface, Sequelize) { + // Remove indexes from users table + await queryInterface.removeIndex('users', ['tenant_id']); + await queryInterface.removeIndex('users', ['external_provider']); + await queryInterface.removeIndex('users', 'users_external_id_tenant_unique'); + + // Remove columns from users table + await queryInterface.removeColumn('users', 'tenant_id'); + await queryInterface.removeColumn('users', 'external_provider'); + await queryInterface.removeColumn('users', 'external_id'); + + // Remove tenant_id from other tables + try { + await queryInterface.removeColumn('devices', 'tenant_id'); + } catch (error) { + console.log('Devices table tenant_id column not found'); + } + + try { + await queryInterface.removeColumn('alert_rules', 'tenant_id'); + } catch (error) { + console.log('Alert_rules table tenant_id column not found'); + } + + // Drop ENUMs + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_users_external_provider"'); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_tenants_auth_provider"'); + await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_tenants_subscription_type"'); + + // Drop tenants table + await queryInterface.dropTable('tenants'); + + console.log('โœ… Multi-tenant support removed'); + } +}; diff --git a/server/models/Tenant.js b/server/models/Tenant.js new file mode 100644 index 0000000..b2b5e65 --- /dev/null +++ b/server/models/Tenant.js @@ -0,0 +1,213 @@ +/** + * Tenant Model for Multi-Tenant Support + * Stores tenant-specific configuration including authentication providers + */ + +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const Tenant = sequelize.define('Tenant', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: { + type: DataTypes.STRING, + allowNull: false, + comment: 'Human-readable tenant name' + }, + slug: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + validate: { + is: /^[a-z0-9-]+$/i // Alphanumeric and hyphens only + }, + comment: 'URL-safe tenant identifier (subdomain/path)' + }, + domain: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Custom domain for this tenant' + }, + subscription_type: { + type: DataTypes.ENUM('free', 'basic', 'premium', 'enterprise'), + defaultValue: 'basic', + comment: 'Subscription tier' + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: 'Whether tenant is active' + }, + + // Authentication Configuration + auth_provider: { + type: DataTypes.ENUM('local', 'saml', 'oauth', 'ldap', 'custom_sso'), + defaultValue: 'local', + comment: 'Primary authentication provider' + }, + auth_config: { + type: DataTypes.JSONB, + allowNull: true, + comment: 'Authentication provider configuration (encrypted)' + }, + user_mapping: { + type: DataTypes.JSONB, + allowNull: true, + comment: 'User attribute mapping from external provider' + }, + role_mapping: { + type: DataTypes.JSONB, + allowNull: true, + comment: 'Role mapping from external provider to internal roles' + }, + + // Tenant Customization + branding: { + type: DataTypes.JSONB, + allowNull: true, + comment: 'Tenant-specific branding (logo, colors, etc.)' + }, + features: { + type: DataTypes.JSONB, + defaultValue: { + max_devices: 10, + max_users: 5, + api_rate_limit: 1000, + data_retention_days: 90, + features: ['basic_detection', 'alerts', 'dashboard'] + }, + comment: 'Tenant feature limits and enabled features' + }, + + // Contact Information + admin_email: { + type: DataTypes.STRING, + allowNull: true, + validate: { + isEmail: true + }, + comment: 'Primary admin email for this tenant' + }, + admin_phone: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Primary admin phone for this tenant' + }, + + // Billing Information + billing_email: { + type: DataTypes.STRING, + allowNull: true, + validate: { + isEmail: true + } + }, + payment_method_id: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Stripe/payment provider customer ID' + }, + + // Metadata + metadata: { + type: DataTypes.JSONB, + allowNull: true, + comment: 'Additional tenant metadata' + }, + + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW + } + }, { + tableName: 'tenants', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + fields: ['slug'], + unique: true + }, + { + fields: ['domain'], + unique: true, + where: { domain: { [DataTypes.Op.ne]: null } } + }, + { + fields: ['is_active'] + }, + { + fields: ['auth_provider'] + } + ], + hooks: { + beforeSave: (tenant) => { + // Encrypt sensitive auth configuration + if (tenant.auth_config && typeof tenant.auth_config === 'object') { + // In production, encrypt sensitive fields like client_secret, private_key, etc. + const sensitiveFields = ['client_secret', 'private_key', 'bind_password', 'admin_password']; + sensitiveFields.forEach(field => { + if (tenant.auth_config[field]) { + // Simple base64 encoding for demo - use proper encryption in production + tenant.auth_config[field] = Buffer.from(tenant.auth_config[field]).toString('base64'); + } + }); + } + }, + afterFind: (tenants) => { + // Decrypt auth configuration after retrieval + const processOne = (tenant) => { + if (tenant.auth_config && typeof tenant.auth_config === 'object') { + const sensitiveFields = ['client_secret', 'private_key', 'bind_password', 'admin_password']; + sensitiveFields.forEach(field => { + if (tenant.auth_config[field]) { + try { + tenant.auth_config[field] = Buffer.from(tenant.auth_config[field], 'base64').toString(); + } catch (e) { + // Field might not be encrypted, leave as-is + } + } + }); + } + }; + + if (Array.isArray(tenants)) { + tenants.forEach(processOne); + } else if (tenants) { + processOne(tenants); + } + } + } + }); + + // Associations + Tenant.associate = (models) => { + // A tenant has many users + Tenant.hasMany(models.User, { + foreignKey: 'tenant_id', + as: 'users' + }); + + // A tenant has many devices + Tenant.hasMany(models.Device, { + foreignKey: 'tenant_id', + as: 'devices' + }); + + // A tenant has many alert rules + Tenant.hasMany(models.AlertRule, { + foreignKey: 'tenant_id', + as: 'alertRules' + }); + }; + + return Tenant; +}; diff --git a/server/models/User.js b/server/models/User.js index dc4107b..89c353d 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -68,6 +68,25 @@ module.exports = (sequelize) => { defaultValue: 'UTC', comment: 'User timezone for alert scheduling' }, + tenant_id: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'tenants', + key: 'id' + }, + comment: 'Tenant this user belongs to (null for default tenant)' + }, + external_provider: { + type: DataTypes.ENUM('local', 'saml', 'oauth', 'ldap', 'custom_sso'), + defaultValue: 'local', + comment: 'Authentication provider used for this user' + }, + external_id: { + type: DataTypes.STRING, + allowNull: true, + comment: 'User ID from external authentication provider' + }, created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW @@ -90,9 +109,30 @@ module.exports = (sequelize) => { }, { fields: ['phone_number'] + }, + { + fields: ['tenant_id'] + }, + { + fields: ['external_provider'] + }, + { + fields: ['external_id', 'tenant_id'], + unique: true, + name: 'users_external_id_tenant_unique', + where: { external_id: { [DataTypes.Op.ne]: null } } } ] }); + // Associations + User.associate = (models) => { + // User belongs to a tenant + User.belongsTo(models.Tenant, { + foreignKey: 'tenant_id', + as: 'tenant' + }); + }; + return User; }; diff --git a/server/package.json b/server/package.json index ec64f9f..6034f52 100644 --- a/server/package.json +++ b/server/package.json @@ -25,7 +25,13 @@ "bcryptjs": "^2.4.3", "jsonwebtoken": "^9.0.1", "express-rate-limit": "^6.8.1", - "compression": "^1.7.4" + "compression": "^1.7.4", + "passport": "^0.6.0", + "passport-saml": "^3.2.4", + "passport-oauth2": "^1.7.0", + "passport-openidconnect": "^0.1.1", + "ldapjs": "^3.0.7", + "express-session": "^1.17.3" }, "devDependencies": { "nodemon": "^3.0.1", diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..973256c --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,313 @@ +/** + * Multi-Tenant Authentication Routes + * Handles authentication for different providers and tenants + */ + +const express = require('express'); +const router = express.Router(); +const passport = require('passport'); +const session = require('express-session'); +const { Tenant } = require('../models'); +const MultiTenantAuth = require('../middleware/multi-tenant-auth'); +const SAMLAuth = require('../middleware/saml-auth'); +const OAuthAuth = require('../middleware/oauth-auth'); +const LDAPAuth = require('../middleware/ldap-auth'); + +// Initialize multi-tenant auth +const multiAuth = new MultiTenantAuth(); + +// Session middleware for OAuth state management +router.use(session({ + secret: process.env.SESSION_SECRET || 'your-session-secret', + resave: false, + saveUninitialized: false, + cookie: { maxAge: 10 * 60 * 1000 } // 10 minutes +})); + +// Initialize passport +router.use(passport.initialize()); +router.use(passport.session()); + +passport.serializeUser((user, done) => done(null, user)); +passport.deserializeUser((obj, done) => done(null, obj)); + +/** + * GET /auth/config/:tenantId + * Get authentication configuration for a tenant + */ +router.get('/config/:tenantId', async (req, res) => { + try { + const { tenantId } = req.params; + + const tenant = await Tenant.findOne({ where: { slug: tenantId } }); + if (!tenant) { + return res.status(404).json({ + success: false, + message: 'Tenant not found' + }); + } + + // Return public auth configuration (no secrets) + const publicConfig = { + provider: tenant.auth_provider, + enabled: tenant.is_active, + features: { + local_login: tenant.auth_provider === 'local', + sso_login: ['saml', 'oauth', 'ldap'].includes(tenant.auth_provider), + registration: tenant.auth_provider === 'local' + } + }; + + // Add provider-specific public config + if (tenant.auth_provider === 'saml') { + publicConfig.saml = { + login_url: `/auth/saml/${tenantId}/login`, + metadata_url: `/auth/saml/${tenantId}/metadata` + }; + } else if (tenant.auth_provider === 'oauth') { + publicConfig.oauth = { + login_url: `/auth/oauth/${tenantId}/login` + }; + } + + res.json({ + success: true, + data: publicConfig + }); + + } catch (error) { + console.error('Error fetching auth config:', error); + res.status(500).json({ + success: false, + message: 'Failed to fetch authentication configuration' + }); + } +}); + +/** + * POST /auth/login + * Universal login endpoint that routes to appropriate provider + */ +router.post('/login', async (req, res, next) => { + try { + // Determine tenant + const tenantId = await multiAuth.determineTenant(req); + const authConfig = await multiAuth.getTenantAuthConfig(tenantId); + + req.tenant = { id: tenantId, authConfig }; + + // Route based on authentication provider + switch (authConfig.type) { + case 'local': + return require('../routes/user').loginLocal(req, res, next); + + case 'ldap': + const ldapAuth = new LDAPAuth(); + return ldapAuth.authenticate(req, res, next); + + case 'saml': + case 'oauth': + return res.status(400).json({ + success: false, + message: `Please use SSO login for ${authConfig.type} authentication`, + redirect_url: `/auth/${authConfig.type}/${tenantId}/login` + }); + + default: + return res.status(400).json({ + success: false, + message: 'Authentication provider not configured' + }); + } + + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ + success: false, + message: 'Login failed' + }); + } +}); + +/** + * SAML Authentication Routes + */ + +// GET /auth/saml/:tenantId/login - Initiate SAML login +router.get('/saml/:tenantId/login', async (req, res, next) => { + try { + const tenantId = req.params.tenantId; + req.tenant = { + id: tenantId, + authConfig: await multiAuth.getTenantAuthConfig(tenantId) + }; + + const samlAuth = new SAMLAuth(); + return samlAuth.authenticate(req, res, next); + + } catch (error) { + console.error('SAML login error:', error); + res.redirect(`/login?error=saml_error&tenant=${req.params.tenantId}`); + } +}); + +// POST /auth/saml/:tenantId/callback - SAML callback +router.post('/saml/:tenantId/callback', async (req, res, next) => { + const samlAuth = new SAMLAuth(); + return samlAuth.handleCallback(req, res, next); +}); + +// GET /auth/saml/:tenantId/metadata - SAML metadata +router.get('/saml/:tenantId/metadata', async (req, res) => { + try { + const { tenantId } = req.params; + const authConfig = await multiAuth.getTenantAuthConfig(tenantId); + + if (authConfig.type !== 'saml') { + return res.status(404).json({ message: 'SAML not configured for this tenant' }); + } + + const samlAuth = new SAMLAuth(); + const metadata = samlAuth.generateMetadata(tenantId, authConfig.config); + + res.set('Content-Type', 'application/xml'); + res.send(metadata); + + } catch (error) { + console.error('SAML metadata error:', error); + res.status(500).json({ message: 'Failed to generate SAML metadata' }); + } +}); + +/** + * OAuth Authentication Routes + */ + +// GET /auth/oauth/:tenantId/login - Initiate OAuth login +router.get('/oauth/:tenantId/login', async (req, res, next) => { + try { + const tenantId = req.params.tenantId; + req.tenant = { + id: tenantId, + authConfig: await multiAuth.getTenantAuthConfig(tenantId) + }; + + const oauthAuth = new OAuthAuth(); + return oauthAuth.authenticate(req, res, next); + + } catch (error) { + console.error('OAuth login error:', error); + res.redirect(`/login?error=oauth_error&tenant=${req.params.tenantId}`); + } +}); + +// GET /auth/oauth/:tenantId/callback - OAuth callback +router.get('/oauth/:tenantId/callback', async (req, res, next) => { + const oauthAuth = new OAuthAuth(); + return oauthAuth.handleCallback(req, res, next); +}); + +/** + * Logout endpoint for all providers + */ +router.post('/logout', async (req, res) => { + try { + const tenantId = await multiAuth.determineTenant(req); + const authConfig = await multiAuth.getTenantAuthConfig(tenantId); + + // Clear local session + req.logout((err) => { + if (err) console.error('Logout error:', err); + }); + + // Provider-specific logout + if (authConfig.type === 'saml' && authConfig.config.logout_url) { + return res.json({ + success: true, + logout_url: authConfig.config.logout_url, + message: 'Please complete logout with your identity provider' + }); + } + + res.json({ + success: true, + message: 'Logged out successfully' + }); + + } catch (error) { + console.error('Logout error:', error); + res.status(500).json({ + success: false, + message: 'Logout failed' + }); + } +}); + +/** + * Test authentication configuration + */ +router.post('/test/:tenantId', async (req, res) => { + try { + const { tenantId } = req.params; + const authConfig = await multiAuth.getTenantAuthConfig(tenantId); + + let testResult = { success: false, message: 'Unknown provider' }; + + switch (authConfig.type) { + case 'ldap': + const ldapAuth = new LDAPAuth(); + try { + await ldapAuth.testConnection(authConfig.config); + testResult = { success: true, message: 'LDAP connection successful' }; + } catch (error) { + testResult = { success: false, message: error.message }; + } + break; + + case 'local': + testResult = { success: true, message: 'Local authentication ready' }; + break; + + case 'saml': + // Test SAML configuration validity + const requiredSamlFields = ['sso_url', 'certificate', 'issuer']; + const missingSamlFields = requiredSamlFields.filter(field => !authConfig.config[field]); + + if (missingSamlFields.length > 0) { + testResult = { + success: false, + message: `Missing SAML configuration: ${missingSamlFields.join(', ')}` + }; + } else { + testResult = { success: true, message: 'SAML configuration valid' }; + } + break; + + case 'oauth': + // Test OAuth configuration validity + const requiredOAuthFields = ['client_id', 'client_secret', 'authorization_url', 'token_url']; + const missingOAuthFields = requiredOAuthFields.filter(field => !authConfig.config[field]); + + if (missingOAuthFields.length > 0) { + testResult = { + success: false, + message: `Missing OAuth configuration: ${missingOAuthFields.join(', ')}` + }; + } else { + testResult = { success: true, message: 'OAuth configuration valid' }; + } + break; + } + + res.json(testResult); + + } catch (error) { + console.error('Auth test error:', error); + res.status(500).json({ + success: false, + message: 'Authentication test failed' + }); + } +}); + +module.exports = router; diff --git a/server/routes/tenants.js b/server/routes/tenants.js new file mode 100644 index 0000000..0ddce8f --- /dev/null +++ b/server/routes/tenants.js @@ -0,0 +1,381 @@ +/** + * Tenant Management Routes + * Admin interface for managing tenants and their authentication configurations + */ + +const express = require('express'); +const router = express.Router(); +const Joi = require('joi'); +const { Tenant, User } = require('../models'); +const { validateRequest } = require('../middleware/validation'); +const { authenticateToken, requireRole } = require('../middleware/auth'); + +// Validation schemas +const tenantSchema = Joi.object({ + name: Joi.string().required(), + slug: Joi.string().pattern(/^[a-z0-9-]+$/).required(), + domain: Joi.string().optional(), + subscription_type: Joi.string().valid('free', 'basic', 'premium', 'enterprise').default('basic'), + auth_provider: Joi.string().valid('local', 'saml', 'oauth', 'ldap', 'custom_sso').default('local'), + auth_config: Joi.object().optional(), + user_mapping: Joi.object().optional(), + role_mapping: Joi.object().optional(), + branding: Joi.object().optional(), + features: Joi.object().optional(), + admin_email: Joi.string().email().optional(), + admin_phone: Joi.string().optional(), + billing_email: Joi.string().email().optional() +}); + +const authConfigSchema = Joi.object({ + // SAML Configuration + sso_url: Joi.string().uri().when('auth_provider', { is: 'saml', then: Joi.required() }), + certificate: Joi.string().when('auth_provider', { is: 'saml', then: Joi.required() }), + issuer: Joi.string().when('auth_provider', { is: 'saml', then: Joi.required() }), + logout_url: Joi.string().uri().optional(), + + // OAuth Configuration + client_id: Joi.string().when('auth_provider', { is: 'oauth', then: Joi.required() }), + client_secret: Joi.string().when('auth_provider', { is: 'oauth', then: Joi.required() }), + authorization_url: Joi.string().uri().when('auth_provider', { is: 'oauth', then: Joi.required() }), + token_url: Joi.string().uri().when('auth_provider', { is: 'oauth', then: Joi.required() }), + userinfo_url: Joi.string().uri().optional(), + scopes: Joi.array().items(Joi.string()).optional(), + + // LDAP Configuration + url: Joi.string().when('auth_provider', { is: 'ldap', then: Joi.required() }), + base_dn: Joi.string().when('auth_provider', { is: 'ldap', then: Joi.required() }), + bind_dn: Joi.string().optional(), + bind_password: Joi.string().optional(), + user_search_filter: Joi.string().optional(), + domain: Joi.string().optional() +}); + +/** + * GET /api/tenants - List all tenants (super admin only) + */ +router.get('/', authenticateToken, requireRole(['admin']), async (req, res) => { + try { + const { limit = 50, offset = 0, search, auth_provider } = req.query; + + const whereClause = {}; + if (search) { + whereClause[Op.or] = [ + { name: { [Op.iLike]: `%${search}%` } }, + { slug: { [Op.iLike]: `%${search}%` } }, + { domain: { [Op.iLike]: `%${search}%` } } + ]; + } + if (auth_provider) { + whereClause.auth_provider = auth_provider; + } + + const tenants = await Tenant.findAndCountAll({ + where: whereClause, + attributes: { exclude: ['auth_config'] }, // Don't expose auth secrets + include: [{ + model: User, + as: 'users', + attributes: ['id', 'username', 'email', 'role'], + limit: 5 + }], + limit: Math.min(parseInt(limit), 100), + offset: parseInt(offset), + order: [['created_at', 'DESC']] + }); + + res.json({ + success: true, + data: tenants.rows, + pagination: { + total: tenants.count, + limit: parseInt(limit), + offset: parseInt(offset), + pages: Math.ceil(tenants.count / parseInt(limit)) + } + }); + + } catch (error) { + console.error('Error fetching tenants:', error); + res.status(500).json({ + success: false, + message: 'Failed to fetch tenants' + }); + } +}); + +/** + * POST /api/tenants - Create new tenant + */ +router.post('/', authenticateToken, requireRole(['admin']), validateRequest(tenantSchema), async (req, res) => { + try { + const tenantData = req.body; + + // Check if slug is unique + const existingTenant = await Tenant.findOne({ where: { slug: tenantData.slug } }); + if (existingTenant) { + return res.status(409).json({ + success: false, + message: 'Tenant slug already exists' + }); + } + + // Set default features based on subscription type + if (!tenantData.features) { + tenantData.features = getDefaultFeatures(tenantData.subscription_type); + } + + const tenant = await Tenant.create(tenantData); + + res.status(201).json({ + success: true, + data: tenant, + message: 'Tenant created successfully' + }); + + } catch (error) { + console.error('Error creating tenant:', error); + res.status(500).json({ + success: false, + message: 'Failed to create tenant' + }); + } +}); + +/** + * GET /api/tenants/:id - Get tenant details + */ +router.get('/:id', authenticateToken, requireRole(['admin']), async (req, res) => { + try { + const tenant = await Tenant.findByPk(req.params.id, { + include: [{ + model: User, + as: 'users', + attributes: ['id', 'username', 'email', 'role', 'last_login', 'created_at'] + }] + }); + + if (!tenant) { + return res.status(404).json({ + success: false, + message: 'Tenant not found' + }); + } + + // Mask sensitive auth configuration + const tenantData = tenant.toJSON(); + if (tenantData.auth_config) { + tenantData.auth_config = maskSensitiveConfig(tenantData.auth_config); + } + + res.json({ + success: true, + data: tenantData + }); + + } catch (error) { + console.error('Error fetching tenant:', error); + res.status(500).json({ + success: false, + message: 'Failed to fetch tenant' + }); + } +}); + +/** + * PUT /api/tenants/:id - Update tenant + */ +router.put('/:id', authenticateToken, requireRole(['admin']), async (req, res) => { + try { + const tenant = await Tenant.findByPk(req.params.id); + + if (!tenant) { + return res.status(404).json({ + success: false, + message: 'Tenant not found' + }); + } + + await tenant.update(req.body); + + res.json({ + success: true, + data: tenant, + message: 'Tenant updated successfully' + }); + + } catch (error) { + console.error('Error updating tenant:', error); + res.status(500).json({ + success: false, + message: 'Failed to update tenant' + }); + } +}); + +/** + * PUT /api/tenants/:id/auth-config - Update tenant authentication configuration + */ +router.put('/:id/auth-config', authenticateToken, requireRole(['admin']), async (req, res) => { + try { + const tenant = await Tenant.findByPk(req.params.id); + + if (!tenant) { + return res.status(404).json({ + success: false, + message: 'Tenant not found' + }); + } + + const { auth_provider, auth_config, user_mapping, role_mapping } = req.body; + + // Validate auth configuration based on provider + const validationSchema = authConfigSchema.fork('auth_provider', (schema) => + schema.default(auth_provider) + ); + + const { error } = validationSchema.validate({ auth_provider, ...auth_config }); + if (error) { + return res.status(400).json({ + success: false, + message: 'Invalid authentication configuration', + details: error.details + }); + } + + await tenant.update({ + auth_provider, + auth_config, + user_mapping, + role_mapping + }); + + res.json({ + success: true, + message: 'Authentication configuration updated successfully' + }); + + } catch (error) { + console.error('Error updating auth config:', error); + res.status(500).json({ + success: false, + message: 'Failed to update authentication configuration' + }); + } +}); + +/** + * POST /api/tenants/:id/test-auth - Test authentication configuration + */ +router.post('/:id/test-auth', authenticateToken, requireRole(['admin']), async (req, res) => { + try { + const tenant = await Tenant.findByPk(req.params.id); + + if (!tenant) { + return res.status(404).json({ + success: false, + message: 'Tenant not found' + }); + } + + // Use the auth test endpoint + const authTestRoute = require('./auth'); + req.params.tenantId = tenant.slug; + + return authTestRoute.testAuth(req, res); + + } catch (error) { + console.error('Error testing auth config:', error); + res.status(500).json({ + success: false, + message: 'Failed to test authentication configuration' + }); + } +}); + +/** + * DELETE /api/tenants/:id - Delete tenant (soft delete) + */ +router.delete('/:id', authenticateToken, requireRole(['admin']), async (req, res) => { + try { + const tenant = await Tenant.findByPk(req.params.id); + + if (!tenant) { + return res.status(404).json({ + success: false, + message: 'Tenant not found' + }); + } + + // Soft delete by setting is_active to false + await tenant.update({ is_active: false }); + + res.json({ + success: true, + message: 'Tenant deactivated successfully' + }); + + } catch (error) { + console.error('Error deleting tenant:', error); + res.status(500).json({ + success: false, + message: 'Failed to delete tenant' + }); + } +}); + +/** + * Helper function to get default features based on subscription type + */ +function getDefaultFeatures(subscriptionType) { + const featureMap = { + free: { + max_devices: 2, + max_users: 1, + api_rate_limit: 100, + data_retention_days: 7, + features: ['basic_detection'] + }, + basic: { + max_devices: 10, + max_users: 5, + api_rate_limit: 1000, + data_retention_days: 90, + features: ['basic_detection', 'alerts', 'dashboard'] + }, + premium: { + max_devices: 50, + max_users: 20, + api_rate_limit: 5000, + data_retention_days: 365, + features: ['basic_detection', 'alerts', 'dashboard', 'advanced_analytics', 'api_access'] + }, + enterprise: { + max_devices: -1, // Unlimited + max_users: -1, // Unlimited + api_rate_limit: 50000, + data_retention_days: -1, // Unlimited + features: ['all'] + } + }; + + return featureMap[subscriptionType] || featureMap.basic; +} + +/** + * Helper function to mask sensitive configuration data + */ +function maskSensitiveConfig(config) { + const maskedConfig = { ...config }; + const sensitiveFields = ['client_secret', 'private_key', 'bind_password', 'admin_password']; + + sensitiveFields.forEach(field => { + if (maskedConfig[field]) { + maskedConfig[field] = '****'; + } + }); + + return maskedConfig; +} + +module.exports = router;