Fix jwt-token
This commit is contained in:
113
server/config/auth-providers.js
Normal file
113
server/config/auth-providers.js
Normal 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
|
||||
};
|
||||
325
server/middleware/ldap-auth.js
Normal file
325
server/middleware/ldap-auth.js
Normal 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;
|
||||
284
server/middleware/multi-tenant-auth.js
Normal file
284
server/middleware/multi-tenant-auth.js
Normal 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;
|
||||
245
server/middleware/oauth-auth.js
Normal file
245
server/middleware/oauth-auth.js
Normal 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;
|
||||
197
server/middleware/saml-auth.js
Normal file
197
server/middleware/saml-auth.js
Normal 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;
|
||||
267
server/migrations/20250912000001-add-multi-tenant-support.js
Normal file
267
server/migrations/20250912000001-add-multi-tenant-support.js
Normal 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
213
server/models/Tenant.js
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
313
server/routes/auth.js
Normal 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
381
server/routes/tenants.js
Normal 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;
|
||||
Reference in New Issue
Block a user