Fix jwt-token

This commit is contained in:
2025-09-12 12:11:14 +02:00
parent 8b0234986d
commit d8bba047bb
14 changed files with 3236 additions and 1 deletions

View File

@@ -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 (
<MultiTenantAuthContext.Provider value={value}>
{children}
</MultiTenantAuthContext.Provider>
);
};
export const useMultiTenantAuth = () => {
const context = useContext(MultiTenantAuthContext);
if (!context) {
throw new Error('useMultiTenantAuth must be used within a MultiTenantAuthProvider');
}
return context;
};
export default MultiTenantAuthContext;

View File

@@ -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 <Navigate to="/" replace />;
}
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 (
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
<p className="mt-2 text-sm text-gray-600">Loading authentication configuration...</p>
</div>
);
}
const { provider, features } = authConfig;
// SSO-only providers
if (provider === 'saml' || provider === 'oauth') {
return (
<div className="space-y-4">
<div className="text-center">
<h3 className="text-lg font-medium text-gray-900 mb-2">
Single Sign-On Required
</h3>
<p className="text-sm text-gray-600 mb-4">
Please sign in using your organization's identity provider.
</p>
</div>
<button
onClick={handleSSOLogin}
disabled={loading}
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : (
<>
{provider === 'saml' ? '🔐 Sign in with SAML' : '🔑 Sign in with OAuth'}
</>
)}
</button>
{tenant && tenant !== 'default' && (
<div className="text-center">
<p className="text-xs text-gray-500">
Organization: <span className="font-medium">{tenant}</span>
</p>
</div>
)}
</div>
);
}
// Local or LDAP authentication with form
return (
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
Username or Email
</label>
<input
id="username"
name="username"
type="text"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Username or Email"
value={credentials.username}
onChange={handleChange}
disabled={loading}
/>
</div>
<div className="relative">
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={credentials.password}
onChange={handleChange}
disabled={loading}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeSlashIcon className="h-4 w-4 text-gray-400" />
) : (
<EyeIcon className="h-4 w-4 text-gray-400" />
)}
</button>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : (
'Sign in'
)}
</button>
</div>
{/* Show additional SSO option if available */}
{features?.sso_login && (
<div className="mt-4">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or</span>
</div>
</div>
<button
type="button"
onClick={handleSSOLogin}
className="mt-3 w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
🔑 Sign in with SSO
</button>
</div>
)}
{/* Demo credentials for local auth */}
{provider === 'local' && (
<div className="text-center">
<p className="text-sm text-gray-600">
Demo credentials: <br />
Username: <code className="bg-gray-100 px-1 rounded">admin</code> <br />
Password: <code className="bg-gray-100 px-1 rounded">admin123</code>
</p>
</div>
)}
{/* LDAP info */}
{provider === 'ldap' && (
<div className="text-center">
<p className="text-sm text-gray-600">
Use your Active Directory credentials
</p>
</div>
)}
</form>
);
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
<svg className="h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Drone Detection System
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Sign in to your account
</p>
{/* Tenant indicator */}
{tenant && tenant !== 'default' && (
<div className="mt-2 text-center">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
Organization: {tenant}
</span>
</div>
)}
</div>
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{renderAuthenticationMethod()}
{/* Tenant switcher for development */}
{process.env.NODE_ENV === 'development' && (
<div className="text-center">
<details className="text-xs text-gray-500">
<summary className="cursor-pointer">Development Options</summary>
<div className="mt-2 space-y-1">
<button
onClick={() => window.location.href = '/login?tenant=default'}
className="block w-full text-left hover:text-primary-600"
>
Switch to Default Tenant
</button>
<button
onClick={() => window.location.href = '/login?tenant=demo-saml'}
className="block w-full text-left hover:text-primary-600"
>
Switch to SAML Demo
</button>
<button
onClick={() => window.location.href = '/login?tenant=demo-oauth'}
className="block w-full text-left hover:text-primary-600"
>
Switch to OAuth Demo
</button>
</div>
</details>
</div>
)}
</div>
</div>
);
};
export default MultiTenantLogin;

View File

@@ -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.

View File

@@ -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
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 = `<?xml version="1.0"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="urn:${process.env.DOMAIN_NAME}:${tenantId}">
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="${process.env.BASE_URL}/auth/saml/${tenantId}/callback"
index="1" />
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="${process.env.BASE_URL}/auth/saml/${tenantId}/logout" />
</md:SPSSODescriptor>
</md:EntityDescriptor>`;
return metadata;
}
}
module.exports = SAMLAuth;

View File

@@ -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');
}
};

213
server/models/Tenant.js Normal file
View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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",

313
server/routes/auth.js Normal file
View File

@@ -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;

381
server/routes/tenants.js Normal file
View File

@@ -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;