📋 Documentation Updated 1. PROJECT_OVERVIEW.md - Complete 420-line project summary 2. README.md - Transformed into comprehensive 450+ line guide 3. API_GUIDE.md - Enhanced with Node.js/Axios examples 4. API_SECURITY.md - Added Node.js security implementations 5. CLAUDE.md - Updated with latest API client information 🎯 Project Status Your ERPNext Docker deployment now provides: - Complete API Integration: 771 DocTypes documented - Dual Language Support: Python + Node.js/Axios clients - Enterprise Security: Token auth, rate limiting, audit logging - Production Ready: Comprehensive testing and validation 🚀 Ready to Use # Start ERPNext docker network create erpnext-local docker-compose up -d # Test API clients python3 secure_api_client.py node secure_api_client.js All documentation is up-to-date and reflects the complete work accomplished. The project is production-ready with enterprise-grade API integration capabilities.
575 lines
20 KiB
JavaScript
Executable File
575 lines
20 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
|
||
/**
|
||
* Secure ERPNext API Client - Node.js/Axios Version
|
||
* Demonstrates best practices for API authentication and security
|
||
*/
|
||
|
||
const axios = require('axios');
|
||
const fs = require('fs').promises;
|
||
const readline = require('readline');
|
||
const crypto = require('crypto');
|
||
const { URL } = require('url');
|
||
|
||
class ERPNextSecureClient {
|
||
constructor(baseUrl = 'http://localhost:8080') {
|
||
this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
||
this.authMethod = null;
|
||
this.currentUser = 'unknown';
|
||
|
||
// Create axios instance with security defaults
|
||
this.client = axios.create({
|
||
baseURL: this.baseUrl,
|
||
timeout: 30000, // 30 second timeout
|
||
headers: {
|
||
'User-Agent': 'ERPNext-Secure-Client-JS/1.0',
|
||
'Accept': 'application/json',
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
|
||
// Security check
|
||
const url = new URL(this.baseUrl);
|
||
if (url.protocol === 'http:' && !url.hostname.includes('localhost')) {
|
||
console.warn('⚠️ WARNING: Using HTTP with non-localhost. Use HTTPS in production!');
|
||
}
|
||
|
||
// Add request/response interceptors for security
|
||
this.setupInterceptors();
|
||
}
|
||
|
||
setupInterceptors() {
|
||
// Request interceptor - add security headers and logging
|
||
this.client.interceptors.request.use(
|
||
(config) => {
|
||
// Add timestamp for audit
|
||
config.headers['X-Request-Time'] = new Date().toISOString();
|
||
|
||
// Add request ID for tracing
|
||
config.headers['X-Request-ID'] = this.generateRequestId();
|
||
|
||
return config;
|
||
},
|
||
(error) => {
|
||
this.logRequest('REQUEST_ERROR', '', 0, error.message);
|
||
return Promise.reject(error);
|
||
}
|
||
);
|
||
|
||
// Response interceptor - handle auth errors and logging
|
||
this.client.interceptors.response.use(
|
||
(response) => {
|
||
// Log successful requests
|
||
this.logRequest(
|
||
response.config.method.toUpperCase(),
|
||
response.config.url,
|
||
response.status
|
||
);
|
||
return response;
|
||
},
|
||
(error) => {
|
||
const status = error.response?.status || 0;
|
||
const method = error.config?.method?.toUpperCase() || 'UNKNOWN';
|
||
const url = error.config?.url || '';
|
||
|
||
// Handle specific error cases
|
||
if (status === 401) {
|
||
console.error('❌ Authentication failed. Token may be expired.');
|
||
} else if (status === 403) {
|
||
console.error('❌ Access forbidden. Check permissions.');
|
||
} else if (status === 429) {
|
||
console.error('❌ Rate limit exceeded. Please wait.');
|
||
}
|
||
|
||
this.logRequest(method, url, status, error.message);
|
||
return Promise.reject(error);
|
||
}
|
||
);
|
||
}
|
||
|
||
generateRequestId() {
|
||
return crypto.randomBytes(8).toString('hex');
|
||
}
|
||
|
||
/**
|
||
* Login using username/password (creates session cookie)
|
||
* SECURITY: Use only for web applications, not for API clients
|
||
*/
|
||
async loginWithCredentials(username, password) {
|
||
if (!username) {
|
||
username = await this.promptInput('Username: ');
|
||
}
|
||
if (!password) {
|
||
password = await this.promptPassword('Password: ');
|
||
}
|
||
|
||
const loginData = { usr: username, pwd: password };
|
||
|
||
try {
|
||
const response = await this.client.post('/api/method/login', loginData);
|
||
|
||
if (response.data.message && response.data.message.includes('Logged In')) {
|
||
this.authMethod = 'session';
|
||
this.currentUser = username;
|
||
console.log('✅ Logged in successfully (session-based)');
|
||
this.logAuthEvent('LOGIN_SUCCESS', username);
|
||
return true;
|
||
} else {
|
||
console.log('❌ Login failed');
|
||
this.logAuthEvent('LOGIN_FAILED', username);
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ Login error: ${error.message}`);
|
||
this.logAuthEvent('LOGIN_ERROR', username, error.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Setup token-based authentication
|
||
* SECURITY: Recommended for API clients and server-to-server communication
|
||
*/
|
||
async authenticateWithToken(apiKey, apiSecret) {
|
||
if (!apiKey) {
|
||
apiKey = process.env.ERPNEXT_API_KEY;
|
||
if (!apiKey) {
|
||
apiKey = await this.promptInput('API Key: ');
|
||
}
|
||
}
|
||
|
||
if (!apiSecret) {
|
||
apiSecret = process.env.ERPNEXT_API_SECRET;
|
||
if (!apiSecret) {
|
||
apiSecret = await this.promptPassword('API Secret: ');
|
||
}
|
||
}
|
||
|
||
this.apiKey = apiKey;
|
||
this.apiSecret = apiSecret;
|
||
this.authMethod = 'token';
|
||
|
||
// Set authorization header
|
||
this.client.defaults.headers.common['Authorization'] = `token ${apiKey}:${apiSecret}`;
|
||
|
||
// Test the token
|
||
try {
|
||
await this.get('/api/resource/User', { limit_page_length: 1 });
|
||
console.log('✅ Token authentication successful');
|
||
this.logAuthEvent('TOKEN_AUTH_SUCCESS', `${apiKey.substring(0, 8)}...`);
|
||
return true;
|
||
} catch (error) {
|
||
console.error(`❌ Token authentication failed: ${error.message}`);
|
||
this.logAuthEvent('TOKEN_AUTH_FAILED', `${apiKey.substring(0, 8)}...`, error.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
generateApiKeyInstructions() {
|
||
console.log('\n' + '='.repeat(60));
|
||
console.log('HOW TO GENERATE API KEYS:');
|
||
console.log('='.repeat(60));
|
||
console.log('1. Login to ERPNext web interface');
|
||
console.log('2. Go to Settings → My Settings');
|
||
console.log('3. Scroll to \'API Access\' section');
|
||
console.log('4. Click \'Generate Keys\'');
|
||
console.log('5. Copy the API Key and API Secret');
|
||
console.log('6. Store them securely (environment variables recommended)');
|
||
console.log('\nEnvironment Variables:');
|
||
console.log('export ERPNEXT_API_KEY=\'your_api_key_here\'');
|
||
console.log('export ERPNEXT_API_SECRET=\'your_api_secret_here\'');
|
||
console.log('\nNode.js (.env file):');
|
||
console.log('ERPNEXT_API_KEY=your_api_key_here');
|
||
console.log('ERPNEXT_API_SECRET=your_api_secret_here');
|
||
console.log('='.repeat(60));
|
||
}
|
||
|
||
/**
|
||
* Secure API Methods
|
||
*/
|
||
async get(endpoint, params = {}) {
|
||
this.checkAuth();
|
||
const response = await this.client.get(endpoint, { params });
|
||
return response.data;
|
||
}
|
||
|
||
async post(endpoint, data = {}) {
|
||
this.checkAuth();
|
||
const response = await this.client.post(endpoint, data);
|
||
return response.data;
|
||
}
|
||
|
||
async put(endpoint, data = {}) {
|
||
this.checkAuth();
|
||
const response = await this.client.put(endpoint, data);
|
||
return response.data;
|
||
}
|
||
|
||
async delete(endpoint) {
|
||
this.checkAuth();
|
||
const response = await this.client.delete(endpoint);
|
||
return response.data;
|
||
}
|
||
|
||
checkAuth() {
|
||
if (!this.authMethod) {
|
||
throw new Error('Not authenticated. Use loginWithCredentials() or authenticateWithToken()');
|
||
}
|
||
}
|
||
|
||
async logout() {
|
||
if (this.authMethod === 'session') {
|
||
try {
|
||
await this.client.post('/api/method/logout');
|
||
console.log('✅ Logged out successfully');
|
||
} catch (error) {
|
||
// Ignore logout errors
|
||
}
|
||
}
|
||
|
||
// Clear auth headers and reset state
|
||
delete this.client.defaults.headers.common['Authorization'];
|
||
this.authMethod = null;
|
||
this.currentUser = 'unknown';
|
||
console.log('🔒 Session cleared');
|
||
}
|
||
|
||
/**
|
||
* Utility Methods
|
||
*/
|
||
async promptInput(question) {
|
||
const rl = readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout
|
||
});
|
||
|
||
return new Promise((resolve) => {
|
||
rl.question(question, (answer) => {
|
||
rl.close();
|
||
resolve(answer);
|
||
});
|
||
});
|
||
}
|
||
|
||
async promptPassword(question) {
|
||
const rl = readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout
|
||
});
|
||
|
||
return new Promise((resolve) => {
|
||
rl.question(question, (answer) => {
|
||
rl.close();
|
||
resolve(answer);
|
||
});
|
||
rl.stdoutMuted = true;
|
||
rl._writeToOutput = function _writeToOutput(stringToWrite) {
|
||
if (rl.stdoutMuted) {
|
||
rl.output.write('*');
|
||
} else {
|
||
rl.output.write(stringToWrite);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
async logAuthEvent(event, user, details = '') {
|
||
const timestamp = new Date().toISOString();
|
||
const logEntry = `${timestamp} - ${event} - User: ${user} - ${details}\n`;
|
||
|
||
try {
|
||
await fs.appendFile('api_security.log', logEntry);
|
||
} catch (error) {
|
||
// Don't fail if logging fails
|
||
}
|
||
}
|
||
|
||
async logRequest(method, endpoint, statusCode, error = '') {
|
||
const timestamp = new Date().toISOString();
|
||
const logEntry = `${timestamp} - ${method} ${endpoint} - ${statusCode} - User: ${this.currentUser} - ${error}\n`;
|
||
|
||
try {
|
||
await fs.appendFile('api_requests.log', logEntry);
|
||
} catch (error) {
|
||
// Don't fail if logging fails
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Advanced Secure Client with additional features
|
||
*/
|
||
class ERPNextAdvancedSecureClient extends ERPNextSecureClient {
|
||
constructor(baseUrl, options = {}) {
|
||
super(baseUrl);
|
||
|
||
this.options = {
|
||
retryAttempts: options.retryAttempts || 3,
|
||
retryDelay: options.retryDelay || 1000,
|
||
enableCache: options.enableCache || false,
|
||
cacheTimeout: options.cacheTimeout || 300000, // 5 minutes
|
||
...options
|
||
};
|
||
|
||
this.cache = new Map();
|
||
this.setupRetryLogic();
|
||
|
||
if (options.rateLimitPerMinute) {
|
||
this.setupRateLimit(options.rateLimitPerMinute);
|
||
}
|
||
}
|
||
|
||
setupRetryLogic() {
|
||
// Add retry interceptor
|
||
this.client.interceptors.response.use(
|
||
response => response,
|
||
async (error) => {
|
||
const config = error.config;
|
||
|
||
// Don't retry if we've exceeded max attempts or it's not a retriable error
|
||
if (!config || config.retry >= this.options.retryAttempts) {
|
||
return Promise.reject(error);
|
||
}
|
||
|
||
// Only retry on network errors or 5xx status codes
|
||
const shouldRetry = !error.response ||
|
||
(error.response.status >= 500 && error.response.status < 600);
|
||
|
||
if (!shouldRetry) {
|
||
return Promise.reject(error);
|
||
}
|
||
|
||
config.retry = (config.retry || 0) + 1;
|
||
|
||
console.log(`🔄 Retrying request (${config.retry}/${this.options.retryAttempts}): ${config.url}`);
|
||
|
||
// Wait before retrying
|
||
await new Promise(resolve => setTimeout(resolve, this.options.retryDelay * config.retry));
|
||
|
||
return this.client(config);
|
||
}
|
||
);
|
||
}
|
||
|
||
setupRateLimit(requestsPerMinute) {
|
||
this.rateLimitQueue = [];
|
||
this.rateLimitWindow = 60000; // 1 minute
|
||
this.maxRequests = requestsPerMinute;
|
||
|
||
this.client.interceptors.request.use(async (config) => {
|
||
await this.checkRateLimit();
|
||
return config;
|
||
});
|
||
}
|
||
|
||
async checkRateLimit() {
|
||
const now = Date.now();
|
||
|
||
// Remove old requests outside the window
|
||
this.rateLimitQueue = this.rateLimitQueue.filter(
|
||
timestamp => now - timestamp < this.rateLimitWindow
|
||
);
|
||
|
||
// Check if we're at the limit
|
||
if (this.rateLimitQueue.length >= this.maxRequests) {
|
||
const oldestRequest = Math.min(...this.rateLimitQueue);
|
||
const waitTime = this.rateLimitWindow - (now - oldestRequest);
|
||
|
||
console.log(`⏳ Rate limit reached. Waiting ${Math.ceil(waitTime / 1000)}s...`);
|
||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||
}
|
||
|
||
this.rateLimitQueue.push(now);
|
||
}
|
||
|
||
async get(endpoint, params = {}) {
|
||
// Check cache first
|
||
if (this.options.enableCache) {
|
||
const cacheKey = `GET:${endpoint}:${JSON.stringify(params)}`;
|
||
const cached = this.cache.get(cacheKey);
|
||
|
||
if (cached && Date.now() - cached.timestamp < this.options.cacheTimeout) {
|
||
console.log(`💾 Cache hit: ${endpoint}`);
|
||
return cached.data;
|
||
}
|
||
}
|
||
|
||
const result = await super.get(endpoint, params);
|
||
|
||
// Cache the result
|
||
if (this.options.enableCache) {
|
||
const cacheKey = `GET:${endpoint}:${JSON.stringify(params)}`;
|
||
this.cache.set(cacheKey, {
|
||
data: result,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
clearCache() {
|
||
this.cache.clear();
|
||
console.log('🗑️ Cache cleared');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Demo Functions
|
||
*/
|
||
async function demoSecureUsage() {
|
||
console.log('ERPNext Secure API Client Demo (Node.js/Axios)');
|
||
console.log('='.repeat(50));
|
||
|
||
const client = new ERPNextSecureClient();
|
||
|
||
// Method 1: API Token (Recommended for APIs)
|
||
console.log('\n🔐 Method 1: API Token Authentication (Recommended)');
|
||
console.log('-'.repeat(50));
|
||
|
||
const success = await client.authenticateWithToken();
|
||
|
||
if (success) {
|
||
try {
|
||
console.log('\n📊 Fetching system info...');
|
||
const systemSettings = await client.get('/api/resource/System%20Settings/System%20Settings');
|
||
if (systemSettings?.data) {
|
||
console.log(` Country: ${systemSettings.data.country || 'Not set'}`);
|
||
console.log(` Time Zone: ${systemSettings.data.time_zone || 'Not set'}`);
|
||
}
|
||
|
||
console.log('\n👥 Fetching users (limited)...');
|
||
const users = await client.get('/api/resource/User', { limit_page_length: 3 });
|
||
if (users?.data) {
|
||
users.data.forEach(user => {
|
||
console.log(` - ${user.full_name || 'Unknown'} (${user.name || 'unknown'})`);
|
||
});
|
||
}
|
||
|
||
console.log('\n🏢 Checking companies...');
|
||
const companies = await client.get('/api/resource/Company');
|
||
if (companies?.data) {
|
||
companies.data.forEach(company => {
|
||
console.log(` - ${company.name || 'Unknown Company'}`);
|
||
});
|
||
}
|
||
|
||
// Demo creating a customer (if you have permissions)
|
||
console.log('\n👤 Demo: Creating a test customer...');
|
||
try {
|
||
const newCustomer = await client.post('/api/resource/Customer', {
|
||
customer_name: 'Test Customer JS',
|
||
customer_type: 'Individual',
|
||
customer_group: 'All Customer Groups',
|
||
territory: 'All Territories'
|
||
});
|
||
console.log(` ✅ Created customer: ${newCustomer.data.name}`);
|
||
} catch (error) {
|
||
console.log(` ℹ️ Skipping customer creation (permission/validation error)`);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error during API calls: ${error.message}`);
|
||
}
|
||
|
||
await client.logout();
|
||
} else {
|
||
client.generateApiKeyInstructions();
|
||
}
|
||
}
|
||
|
||
async function demoAdvancedFeatures() {
|
||
console.log('\n🚀 Advanced Features Demo');
|
||
console.log('-'.repeat(50));
|
||
|
||
const advancedClient = new ERPNextAdvancedSecureClient('http://localhost:8080', {
|
||
retryAttempts: 3,
|
||
retryDelay: 1000,
|
||
enableCache: true,
|
||
cacheTimeout: 60000, // 1 minute cache
|
||
rateLimitPerMinute: 30
|
||
});
|
||
|
||
if (await advancedClient.authenticateWithToken()) {
|
||
console.log('\n💾 Testing caching...');
|
||
|
||
// First request (will be cached)
|
||
console.time('First request');
|
||
await advancedClient.get('/api/resource/User', { limit_page_length: 1 });
|
||
console.timeEnd('First request');
|
||
|
||
// Second request (from cache)
|
||
console.time('Cached request');
|
||
await advancedClient.get('/api/resource/User', { limit_page_length: 1 });
|
||
console.timeEnd('Cached request');
|
||
|
||
advancedClient.clearCache();
|
||
await advancedClient.logout();
|
||
}
|
||
}
|
||
|
||
function printSecurityRecommendations() {
|
||
console.log('\n' + '='.repeat(60));
|
||
console.log('🔒 SECURITY RECOMMENDATIONS');
|
||
console.log('='.repeat(60));
|
||
console.log('1. ✅ USE API TOKENS for server-to-server communication');
|
||
console.log('2. ✅ USE HTTPS in production (never HTTP)');
|
||
console.log('3. ✅ STORE credentials in environment variables (.env)');
|
||
console.log('4. ✅ IMPLEMENT rate limiting and retry logic');
|
||
console.log('5. ✅ LOG all API access for audit trails');
|
||
console.log('6. ✅ VALIDATE all inputs and handle errors gracefully');
|
||
console.log('7. ✅ USE request timeouts and proper error handling');
|
||
console.log('8. ✅ IMPLEMENT caching for frequently accessed data');
|
||
console.log('9. ✅ MONITOR API usage and performance metrics');
|
||
console.log('10. ✅ ROTATE API keys regularly (every 90 days)');
|
||
console.log('\n❌ AVOID:');
|
||
console.log('- Never commit API keys to version control');
|
||
console.log('- Never use HTTP in production');
|
||
console.log('- Never ignore SSL certificate errors');
|
||
console.log('- Never expose API keys in client-side code');
|
||
console.log('- Never log sensitive data');
|
||
console.log('='.repeat(60));
|
||
|
||
console.log('\n📦 Required Dependencies:');
|
||
console.log('npm install axios dotenv');
|
||
console.log('\n📄 Example .env file:');
|
||
console.log('ERPNEXT_API_KEY=your_api_key_here');
|
||
console.log('ERPNEXT_API_SECRET=your_api_secret_here');
|
||
console.log('ERPNEXT_URL=https://your-domain.com');
|
||
}
|
||
|
||
// Main execution
|
||
async function main() {
|
||
try {
|
||
// Load environment variables if available
|
||
try {
|
||
require('dotenv').config();
|
||
} catch (error) {
|
||
console.log('💡 Tip: Install dotenv for .env file support: npm install dotenv');
|
||
}
|
||
|
||
await demoSecureUsage();
|
||
await demoAdvancedFeatures();
|
||
printSecurityRecommendations();
|
||
} catch (error) {
|
||
if (error.code === 'ECONNREFUSED') {
|
||
console.error('\n❌ Connection refused. Make sure ERPNext is running on http://localhost:8080');
|
||
} else {
|
||
console.error(`\n❌ Unexpected error: ${error.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Export classes for use as modules
|
||
module.exports = {
|
||
ERPNextSecureClient,
|
||
ERPNextAdvancedSecureClient
|
||
};
|
||
|
||
// Run demo if called directly
|
||
if (require.main === module) {
|
||
main().catch(error => {
|
||
console.error('Fatal error:', error.message);
|
||
process.exit(1);
|
||
});
|
||
} |