mirror of
https://github.com/StuffAnThings/qbit_manage.git
synced 2025-11-10 16:30:54 +08:00
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:
parent
ddeb49a260
commit
a505a0dcec
4 changed files with 133 additions and 47 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
4.6.1-develop22
|
4.6.1-develop23
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,7 @@ class WebAPI:
|
||||||
|
|
||||||
# Authentication routes
|
# Authentication routes
|
||||||
api_router.get("/security")(self.get_security_settings)
|
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)
|
api_router.put("/security")(self.update_security_settings)
|
||||||
|
|
||||||
# System management routes
|
# System management routes
|
||||||
|
|
@ -1673,12 +1674,29 @@ class WebAPI:
|
||||||
|
|
||||||
# Don't return sensitive information for security
|
# Don't return sensitive information for security
|
||||||
settings.password_hash = "***" if settings.password_hash else ""
|
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
|
return settings
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting security settings: {str(e)}")
|
logger.error(f"Error getting security settings: {str(e)}")
|
||||||
return AuthSettings()
|
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]:
|
async def update_security_settings(self, request: SecuritySettingsRequest, req: Request) -> dict[str, Any]:
|
||||||
"""Update security settings."""
|
"""Update security settings."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -1828,7 +1846,7 @@ class WebAPI:
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Security settings updated successfully",
|
"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:
|
else:
|
||||||
return {"success": False, "message": "Failed to save security settings"}
|
return {"success": False, "message": "Failed to save security settings"}
|
||||||
|
|
|
||||||
|
|
@ -66,3 +66,43 @@
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
background-color: var(--bg-secondary);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { API } from '../api.js';
|
import { API } from '../api.js';
|
||||||
import { showToast } from '../utils/toast.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';
|
import { EYE_ICON_SVG, EYE_SLASH_ICON_SVG } from '../utils/icons.js';
|
||||||
|
|
||||||
export class SecurityComponent {
|
export class SecurityComponent {
|
||||||
|
|
@ -12,6 +13,7 @@ export class SecurityComponent {
|
||||||
this.container = document.getElementById(containerId);
|
this.container = document.getElementById(containerId);
|
||||||
this.api = apiInstance || new API();
|
this.api = apiInstance || new API();
|
||||||
this.currentSettings = null;
|
this.currentSettings = null;
|
||||||
|
this.hasApiKey = false; // Track whether we have an API key without storing the key itself
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
|
@ -28,6 +30,13 @@ export class SecurityComponent {
|
||||||
try {
|
try {
|
||||||
const response = await this.api.makeRequest('/security', 'GET');
|
const response = await this.api.makeRequest('/security', 'GET');
|
||||||
this.currentSettings = response;
|
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) {
|
} catch (error) {
|
||||||
// Use default settings if loading fails
|
// Use default settings if loading fails
|
||||||
this.currentSettings = {
|
this.currentSettings = {
|
||||||
|
|
@ -39,9 +48,19 @@ export class SecurityComponent {
|
||||||
password_hash: '',
|
password_hash: '',
|
||||||
api_key: ''
|
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() {
|
render() {
|
||||||
this.container.innerHTML = `
|
this.container.innerHTML = `
|
||||||
<div class="security-settings">
|
<div class="security-settings">
|
||||||
|
|
@ -132,32 +151,18 @@ export class SecurityComponent {
|
||||||
<div id="api-key-section">
|
<div id="api-key-section">
|
||||||
<h3>API Key</h3>
|
<h3>API Key</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="api-key-display" class="form-label">API Key</label>
|
|
||||||
<div class="api-key-input-group">
|
<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">
|
<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>
|
</button>
|
||||||
${this.currentSettings.api_key ? `
|
${this.hasApiKey ? `
|
||||||
<button type="button" class="btn btn-outline" id="clear-api-key">
|
<button type="button" class="btn btn-outline" id="clear-api-key">
|
||||||
Clear Key
|
Clear Key
|
||||||
</button>
|
</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>
|
||||||
<div class="field-description">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -182,7 +187,6 @@ export class SecurityComponent {
|
||||||
this.credentialsSection = document.getElementById('credentials-section');
|
this.credentialsSection = document.getElementById('credentials-section');
|
||||||
this.generateApiKeyBtn = document.getElementById('generate-api-key');
|
this.generateApiKeyBtn = document.getElementById('generate-api-key');
|
||||||
this.clearApiKeyBtn = document.getElementById('clear-api-key');
|
this.clearApiKeyBtn = document.getElementById('clear-api-key');
|
||||||
this.copyApiKeyBtn = document.getElementById('copy-api-key');
|
|
||||||
this.saveBtn = document.getElementById('save-security-settings');
|
this.saveBtn = document.getElementById('save-security-settings');
|
||||||
|
|
||||||
// Handle authentication method change
|
// 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
|
// Handle save settings
|
||||||
if (this.saveBtn) {
|
if (this.saveBtn) {
|
||||||
this.saveBtn.addEventListener('click', () => {
|
this.saveBtn.addEventListener('click', () => {
|
||||||
|
|
@ -254,6 +251,7 @@ export class SecurityComponent {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standard password field behavior (no special handling for API key anymore)
|
||||||
const isPassword = input.type === 'password';
|
const isPassword = input.type === 'password';
|
||||||
input.type = isPassword ? 'text' : 'password';
|
input.type = isPassword ? 'text' : 'password';
|
||||||
button.innerHTML = isPassword ? EYE_SLASH_ICON_SVG : EYE_ICON_SVG;
|
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);
|
const response = await this.api.makeRequest('/security', 'PUT', requestData);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.currentSettings.api_key = response.api_key;
|
// Store the newly generated API key temporarily for the modal
|
||||||
const input = document.getElementById('api-key-display');
|
const newApiKey = response.api_key;
|
||||||
if (input) {
|
|
||||||
input.value = 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.render();
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
|
||||||
showToast('New API key generated successfully', 'success');
|
showToast('New API key generated successfully', 'success');
|
||||||
} else {
|
} else {
|
||||||
showToast(response.message || 'Failed to generate API key', 'error');
|
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);
|
const response = await this.api.makeRequest('/security', 'PUT', requestData);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.currentSettings.api_key = '';
|
// Clear the API key state
|
||||||
const input = document.getElementById('api-key-display');
|
this.hasApiKey = false;
|
||||||
if (input) {
|
|
||||||
input.value = '';
|
// Re-render to update button states
|
||||||
}
|
|
||||||
// Re-render to update button visibility
|
|
||||||
this.render();
|
this.render();
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
|
|
||||||
showToast('API key cleared successfully', 'success');
|
showToast('API key cleared successfully', 'success');
|
||||||
} else {
|
} else {
|
||||||
showToast(response.message || 'Failed to clear API key', 'error');
|
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) {
|
validatePasswordComplexity(password) {
|
||||||
const minLength = 8;
|
const minLength = 8;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue