Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
303 lines
8.8 KiB
JavaScript
303 lines
8.8 KiB
JavaScript
/**
|
|
* 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();
|