Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
302
dashcaddy-api/auth-manager.js
Normal file
302
dashcaddy-api/auth-manager.js
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Authentication Manager for DashCaddy
|
||||
* Handles JWT tokens and API key generation/validation
|
||||
* Provides defense-in-depth alongside Caddy forward_auth
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const credentialManager = require('./credential-manager');
|
||||
const cryptoUtils = require('./crypto-utils');
|
||||
|
||||
// JWT signing secret - derived from encryption key for consistency
|
||||
const JWT_SECRET = cryptoUtils.loadOrCreateKey();
|
||||
|
||||
// Namespace for API keys in credential manager
|
||||
const API_KEY_NAMESPACE = 'auth.apikey';
|
||||
const API_KEY_METADATA_NAMESPACE = 'auth.metadata';
|
||||
|
||||
class AuthManager {
|
||||
constructor() {
|
||||
this.keyMetadataCache = new Map(); // Cache for API key metadata
|
||||
console.log('[AuthManager] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT token
|
||||
* @param {Object} payload - Token payload (must include sub: userId)
|
||||
* @param {string} expiresIn - Expiration time (default: '24h')
|
||||
* @returns {Promise<string>} JWT token
|
||||
*/
|
||||
async generateJWT(payload, expiresIn = '24h') {
|
||||
try {
|
||||
if (!payload.sub) {
|
||||
throw new Error('JWT payload must include "sub" (subject/userId)');
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
...payload,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
scope: payload.scope || ['read', 'write']
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn }
|
||||
);
|
||||
|
||||
console.log(`[AuthManager] Generated JWT for user: ${payload.sub}, expires in: ${expiresIn}`);
|
||||
return token;
|
||||
} catch (error) {
|
||||
console.error('[AuthManager] JWT generation failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT token
|
||||
* @param {string} token - JWT token to verify
|
||||
* @returns {Promise<Object|null>} Decoded payload or null if invalid
|
||||
*/
|
||||
async verifyJWT(token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
return {
|
||||
userId: decoded.sub,
|
||||
scope: decoded.scope || [],
|
||||
iat: decoded.iat,
|
||||
exp: decoded.exp
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
console.log('[AuthManager] JWT token expired');
|
||||
} else if (error.name === 'JsonWebTokenError') {
|
||||
console.log('[AuthManager] JWT token invalid:', error.message);
|
||||
} else {
|
||||
console.error('[AuthManager] JWT verification failed:', error.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate API key
|
||||
* @param {string} name - Human-readable name for the key
|
||||
* @param {Array<string>} scopes - Permission scopes (default: ['read', 'write'])
|
||||
* @returns {Promise<Object>} { key, id, name, scopes, createdAt }
|
||||
*/
|
||||
async generateAPIKey(name, scopes = ['read', 'write']) {
|
||||
try {
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw new Error('API key name is required');
|
||||
}
|
||||
|
||||
// Generate secure random key (32 bytes = 64 hex chars)
|
||||
const keyId = crypto.randomBytes(16).toString('hex');
|
||||
const keySecret = crypto.randomBytes(32).toString('hex');
|
||||
const apiKey = `dk_${keyId}_${keySecret}`; // dk = DashCaddy Key
|
||||
|
||||
// Store key hash (not the key itself) in credential manager
|
||||
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||
const credentialKey = `${API_KEY_NAMESPACE}.${keyId}`;
|
||||
|
||||
await credentialManager.store(credentialKey, keyHash);
|
||||
|
||||
// Store metadata separately (non-sensitive)
|
||||
const metadata = {
|
||||
id: keyId,
|
||||
name,
|
||||
scopes,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsed: null
|
||||
};
|
||||
|
||||
const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`;
|
||||
await credentialManager.store(metadataKey, JSON.stringify(metadata));
|
||||
|
||||
// Cache metadata
|
||||
this.keyMetadataCache.set(keyId, metadata);
|
||||
|
||||
console.log(`[AuthManager] Generated API key: ${name} (${keyId})`);
|
||||
|
||||
return {
|
||||
key: apiKey,
|
||||
id: keyId,
|
||||
name,
|
||||
scopes,
|
||||
createdAt: metadata.createdAt
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthManager] API key generation failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify API key
|
||||
* @param {string} key - API key to verify
|
||||
* @returns {Promise<Object|null>} { keyId, scopes, name } or null if invalid
|
||||
*/
|
||||
async verifyAPIKey(key) {
|
||||
try {
|
||||
// Parse key format: dk_<keyId>_<secret>
|
||||
if (!key || !key.startsWith('dk_')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = key.split('_');
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyId = parts[1];
|
||||
const credentialKey = `${API_KEY_NAMESPACE}.${keyId}`;
|
||||
|
||||
// Retrieve stored hash
|
||||
const storedHash = await credentialManager.retrieve(credentialKey);
|
||||
if (!storedHash) {
|
||||
console.log(`[AuthManager] API key not found: ${keyId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify key matches stored hash
|
||||
const providedHash = crypto.createHash('sha256').update(key).digest('hex');
|
||||
if (!crypto.timingSafeEqual(Buffer.from(storedHash), Buffer.from(providedHash))) {
|
||||
console.log(`[AuthManager] API key hash mismatch: ${keyId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get metadata
|
||||
const metadata = await this.getKeyMetadata(keyId);
|
||||
if (!metadata) {
|
||||
console.log(`[AuthManager] API key metadata not found: ${keyId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last used timestamp (non-blocking)
|
||||
this.updateLastUsed(keyId, metadata).catch(err =>
|
||||
console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, err.message)
|
||||
);
|
||||
|
||||
console.log(`[AuthManager] API key verified: ${metadata.name} (${keyId})`);
|
||||
|
||||
return {
|
||||
keyId,
|
||||
scopes: metadata.scopes || [],
|
||||
name: metadata.name
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AuthManager] API key verification failed:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke API key
|
||||
* @param {string} keyId - Key ID to revoke
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async revokeAPIKey(keyId) {
|
||||
try {
|
||||
const credentialKey = `${API_KEY_NAMESPACE}.${keyId}`;
|
||||
const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`;
|
||||
|
||||
await credentialManager.delete(credentialKey);
|
||||
await credentialManager.delete(metadataKey);
|
||||
|
||||
this.keyMetadataCache.delete(keyId);
|
||||
|
||||
console.log(`[AuthManager] Revoked API key: ${keyId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[AuthManager] Failed to revoke API key ${keyId}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all API keys (returns metadata, not actual keys)
|
||||
* @returns {Promise<Array<Object>>} Array of API key metadata
|
||||
*/
|
||||
async listAPIKeys() {
|
||||
try {
|
||||
const allKeys = await credentialManager.list();
|
||||
const metadataKeys = allKeys.filter(k => k.startsWith(API_KEY_METADATA_NAMESPACE));
|
||||
|
||||
const keys = [];
|
||||
for (const metaKey of metadataKeys) {
|
||||
const keyId = metaKey.replace(`${API_KEY_METADATA_NAMESPACE}.`, '');
|
||||
const metadata = await this.getKeyMetadata(keyId);
|
||||
if (metadata) {
|
||||
keys.push(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
} catch (error) {
|
||||
console.error('[AuthManager] Failed to list API keys:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a specific API key
|
||||
* @param {string} keyId - Key ID
|
||||
* @returns {Promise<Object|null>} Metadata or null
|
||||
*/
|
||||
async getKeyMetadata(keyId) {
|
||||
try {
|
||||
// Check cache first
|
||||
if (this.keyMetadataCache.has(keyId)) {
|
||||
return this.keyMetadataCache.get(keyId);
|
||||
}
|
||||
|
||||
const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`;
|
||||
const metadataJson = await credentialManager.retrieve(metadataKey);
|
||||
|
||||
if (!metadataJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(metadataJson);
|
||||
this.keyMetadataCache.set(keyId, metadata);
|
||||
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
console.error(`[AuthManager] Failed to get metadata for ${keyId}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last used timestamp for API key
|
||||
* @param {string} keyId - Key ID
|
||||
* @param {Object} metadata - Current metadata
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateLastUsed(keyId, metadata) {
|
||||
try {
|
||||
const updatedMetadata = {
|
||||
...metadata,
|
||||
lastUsed: new Date().toISOString()
|
||||
};
|
||||
|
||||
const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`;
|
||||
await credentialManager.store(metadataKey, JSON.stringify(updatedMetadata));
|
||||
|
||||
this.keyMetadataCache.set(keyId, updatedMetadata);
|
||||
} catch (error) {
|
||||
console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear metadata cache (useful for testing or cache invalidation)
|
||||
*/
|
||||
clearCache() {
|
||||
this.keyMetadataCache.clear();
|
||||
console.log('[AuthManager] Cache cleared');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
module.exports = new AuthManager();
|
||||
Reference in New Issue
Block a user