feat(security): enhance API key security with modal display and status endpoint

Add new /security/status endpoint to retrieve security status without exposing sensitive data.
Update UI to display API key in a modal upon generation and remove direct display for improved security.
This commit is contained in:
bobokun 2025-09-07 20:28:15 -04:00
parent ddeb49a260
commit a505a0dcec
No known key found for this signature in database
GPG key ID: B73932169607D927
4 changed files with 133 additions and 47 deletions

View file

@ -1 +1 @@
4.6.1-develop22
4.6.1-develop23

View file

@ -328,6 +328,7 @@ class WebAPI:
# Authentication routes
api_router.get("/security")(self.get_security_settings)
api_router.get("/security/status")(self.get_security_status)
api_router.put("/security")(self.update_security_settings)
# System management routes
@ -1673,12 +1674,29 @@ class WebAPI:
# Don't return sensitive information for security
settings.password_hash = "***" if settings.password_hash else ""
# Don't return API key for security - it should only be shown once when generated
settings.api_key = ""
return settings
except Exception as e:
logger.error(f"Error getting security settings: {str(e)}")
return AuthSettings()
async def get_security_status(self) -> dict:
"""Get security status information without sensitive data."""
try:
settings_path = Path(self.default_dir) / "qbm_settings.yml"
settings = load_auth_settings(settings_path)
return {
"has_api_key": bool(settings.api_key and settings.api_key.strip()),
"method": settings.method,
"enabled": settings.enabled,
}
except Exception as e:
logger.error(f"Error getting security status: {str(e)}")
return {"has_api_key": False, "method": "none", "enabled": False}
async def update_security_settings(self, request: SecuritySettingsRequest, req: Request) -> dict[str, Any]:
"""Update security settings."""
try:
@ -1828,7 +1846,7 @@ class WebAPI:
return {
"success": True,
"message": "Security settings updated successfully",
"api_key": current_settings.api_key if (request.generate_api_key or request.clear_api_key) else None,
"api_key": current_settings.api_key if request.generate_api_key else None,
}
else:
return {"success": False, "message": "Failed to save security settings"}

View file

@ -66,3 +66,43 @@
border-top: 1px solid var(--border-color);
background-color: var(--bg-secondary);
}
/* API Key Modal Styles */
.api-key-modal {
text-align: center;
}
.api-key-modal p:first-child {
color: var(--warning-color, #d97706);
font-weight: 500;
margin-bottom: var(--spacing-md);
}
.api-key-display-modal {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin: var(--spacing-md) 0;
padding: var(--spacing-md);
background-color: var(--bg-secondary);
border-radius: var(--border-radius-md);
border: 1px solid var(--border-color);
}
.api-key-display-modal .form-input {
flex: 1;
font-family: 'Courier New', monospace;
font-size: var(--font-size-sm);
letter-spacing: 0.5px;
}
.api-key-display-modal .btn {
flex-shrink: 0;
}
.modal-warning {
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-top: var(--spacing-md) !important;
font-style: italic;
}

View file

@ -5,6 +5,7 @@
import { API } from '../api.js';
import { showToast } from '../utils/toast.js';
import { showModal } from '../utils/modal.js';
import { EYE_ICON_SVG, EYE_SLASH_ICON_SVG } from '../utils/icons.js';
export class SecurityComponent {
@ -12,6 +13,7 @@ export class SecurityComponent {
this.container = document.getElementById(containerId);
this.api = apiInstance || new API();
this.currentSettings = null;
this.hasApiKey = false; // Track whether we have an API key without storing the key itself
}
async init() {
@ -28,6 +30,13 @@ export class SecurityComponent {
try {
const response = await this.api.makeRequest('/security', 'GET');
this.currentSettings = response;
// Also fetch security status to determine if API key exists
const statusResponse = await this.api.makeRequest('/security/status', 'GET');
this.hasApiKey = statusResponse.has_api_key;
// Initialize actualApiKey for copy functionality (empty for security)
this.actualApiKey = '';
} catch (error) {
// Use default settings if loading fails
this.currentSettings = {
@ -39,9 +48,19 @@ export class SecurityComponent {
password_hash: '',
api_key: ''
};
this.hasApiKey = false;
this.actualApiKey = '';
}
}
maskApiKey(apiKey) {
// Completely mask the API key for display - show only dots
if (!apiKey) {
return '';
}
return '•'.repeat(Math.max(32, apiKey.length));
}
render() {
this.container.innerHTML = `
<div class="security-settings">
@ -132,32 +151,18 @@ export class SecurityComponent {
<div id="api-key-section">
<h3>API Key</h3>
<div class="form-group">
<label for="api-key-display" class="form-label">API Key</label>
<div class="api-key-input-group">
<div class="password-input-group">
<input type="password" id="api-key-display" class="form-input" readonly value="${this.currentSettings.api_key || ''}" placeholder="No API key generated">
${this.currentSettings.api_key ? `
<button type="button" class="btn btn-icon password-toggle" data-target="api-key-display" title="Show full API key">
${EYE_ICON_SVG}
</button>
` : ''}
</div>
<button type="button" class="btn btn-secondary" id="generate-api-key">
${this.currentSettings.api_key ? 'Generate New Key' : 'Generate Key'}
${this.hasApiKey ? 'Generate New Key' : 'Generate Key'}
</button>
${this.currentSettings.api_key ? `
${this.hasApiKey ? `
<button type="button" class="btn btn-outline" id="clear-api-key">
Clear Key
</button>
` : ''}
<button type="button" class="btn btn-icon" id="copy-api-key" title="Copy to clipboard" ${!this.currentSettings.api_key ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
</button>
</div>
<div class="field-description">
API key for programmatic access. Click "Generate New Key" to create a new one.
API key for programmatic access. Click "Generate Key" to create a new key. The key will be displayed in a popup and won't be shown again.
</div>
</div>
</div>
@ -182,7 +187,6 @@ export class SecurityComponent {
this.credentialsSection = document.getElementById('credentials-section');
this.generateApiKeyBtn = document.getElementById('generate-api-key');
this.clearApiKeyBtn = document.getElementById('clear-api-key');
this.copyApiKeyBtn = document.getElementById('copy-api-key');
this.saveBtn = document.getElementById('save-security-settings');
// Handle authentication method change
@ -215,13 +219,6 @@ export class SecurityComponent {
});
}
// Handle copy API key
if (this.copyApiKeyBtn) {
this.copyApiKeyBtn.addEventListener('click', () => {
this.copyApiKey();
});
}
// Handle save settings
if (this.saveBtn) {
this.saveBtn.addEventListener('click', () => {
@ -254,6 +251,7 @@ export class SecurityComponent {
return;
}
// Standard password field behavior (no special handling for API key anymore)
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
button.innerHTML = isPassword ? EYE_SLASH_ICON_SVG : EYE_ICON_SVG;
@ -289,14 +287,53 @@ export class SecurityComponent {
const response = await this.api.makeRequest('/security', 'PUT', requestData);
if (response.success) {
this.currentSettings.api_key = response.api_key;
const input = document.getElementById('api-key-display');
if (input) {
input.value = response.api_key;
// Store the newly generated API key temporarily for the modal
const newApiKey = response.api_key;
// Show modal with the API key
const modalContent = `
<div class="api-key-modal">
<p><strong>Important:</strong> Copy this API key now. It will not be shown again.</p>
<div class="api-key-display-modal">
<input type="text" id="modal-api-key" class="form-input" readonly value="${newApiKey}">
<button type="button" class="btn btn-icon" id="modal-copy-api-key" title="Copy to clipboard">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
</button>
</div>
<p class="modal-warning">This key provides full access to the qBit Manage API. Keep it secure!</p>
</div>
`;
// Add copy functionality using event delegation before showing modal
const handleCopyClick = (event) => {
if (event.target && event.target.id === 'modal-copy-api-key') {
navigator.clipboard.writeText(newApiKey)
.then(() => showToast('API key copied to clipboard', 'success'))
.catch(() => showToast('Failed to copy API key', 'error'));
}
// Re-render to update button visibility
};
// Attach event listener to document for the copy button
document.addEventListener('click', handleCopyClick);
// Show the modal
const modalResult = await showModal('New API Key Generated', modalContent, {
confirmText: 'Close',
showCancel: false
});
// Clean up event listener after modal is closed
document.removeEventListener('click', handleCopyClick);
// Update the component state to reflect that we now have a key
this.hasApiKey = true;
// Re-render to update button states
this.render();
this.bindEvents();
showToast('New API key generated successfully', 'success');
} else {
showToast(response.message || 'Failed to generate API key', 'error');
@ -335,14 +372,13 @@ export class SecurityComponent {
const response = await this.api.makeRequest('/security', 'PUT', requestData);
if (response.success) {
this.currentSettings.api_key = '';
const input = document.getElementById('api-key-display');
if (input) {
input.value = '';
}
// Re-render to update button visibility
// Clear the API key state
this.hasApiKey = false;
// Re-render to update button states
this.render();
this.bindEvents();
showToast('API key cleared successfully', 'success');
} else {
showToast(response.message || 'Failed to clear API key', 'error');
@ -353,14 +389,6 @@ export class SecurityComponent {
}
}
copyApiKey() {
const input = document.getElementById('api-key-display');
if (!input) return;
navigator.clipboard.writeText(input.value)
.then(() => showToast('API key copied to clipboard', 'success'))
.catch(() => showToast('Failed to copy API key', 'error'));
}
validatePasswordComplexity(password) {
const minLength = 8;