qbit_manage/web-ui/js/components/security.js
bobokun 8b2d904d92
feat(auth): add web authentication feature
Add comprehensive authentication support for the qBit Manage web UI with multiple methods:
- None (default, no authentication)
- Basic HTTP authentication with browser popup
- API-only authentication (web UI accessible, API requires key)

Key features include:
- Secure password hashing using Argon2
- Rate limiting to prevent brute force attacks
- CSRF protection for state-changing requests
- Local IP bypass option for private networks
- API key generation for programmatic access
- New security settings page in the web UI

Adds [FR]: Authentication on WebUI
Fixes #867
2025-09-05 23:06:25 -04:00

432 lines
19 KiB
JavaScript

/**
* Security Component for qBit Manage Web UI
* Handles authentication settings and user management
*/
import { API } from '../api.js';
import { showToast } from '../utils/toast.js';
import { EYE_ICON_SVG, EYE_SLASH_ICON_SVG } from '../utils/icons.js';
export class SecurityComponent {
constructor(containerId, apiInstance = null) {
this.container = document.getElementById(containerId);
this.api = apiInstance || new API();
this.currentSettings = null;
}
async init() {
try {
await this.loadSecuritySettings();
this.render();
this.bindEvents();
} catch (error) {
showToast('Failed to load security settings', 'error');
}
}
async loadSecuritySettings() {
try {
const response = await this.api.makeRequest('/security', 'GET');
this.currentSettings = response;
} catch (error) {
// Use default settings if loading fails
this.currentSettings = {
enabled: false,
method: 'none',
bypass_auth_for_local: false,
username: '',
password_hash: '',
api_key: ''
};
}
}
render() {
this.container.innerHTML = `
<div class="security-settings">
<div class="section-header">
<h2>Security Settings</h2>
<p class="section-description">
Configure authentication for the qBit Manage web interface.
Choose between no authentication or basic HTTP authentication.
</p>
</div>
<div class="settings-form">
<!-- Authentication Method -->
<div class="form-group">
<label for="auth-method" class="form-label">Authentication Method</label>
<select id="auth-method" class="form-select">
<option value="none" ${this.currentSettings.method === 'none' ? 'selected' : ''}>None (Disabled)</option>
<option value="basic" ${this.currentSettings.method === 'basic' ? 'selected' : ''}>Basic (HTTP Authentication)</option>
<option value="api_only" ${this.currentSettings.method === 'api_only' ? 'selected' : ''}>API Only</option>
</select>
<div class="field-description">
<strong>None:</strong> No authentication required<br>
<strong>Basic:</strong> Browser popup for username/password authentication<br>
<strong>API Only:</strong> No auth for web UI, API key required for API requests
</div>
</div>
<!-- Allow Local Addresses -->
<div class="form-group" id="local-addresses-group" style="${this.currentSettings.method === 'none' ? 'display: none;' : ''}">
<label class="checkbox-label">
<input type="checkbox" id="allow-local" ${this.currentSettings.bypass_auth_for_local ? 'checked' : ''}>
<span class="checkmark"></span>
Allow local/private IPs without authentication
</label>
<div class="field-description">
When checked, requests from localhost and RFC 1918 private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) won't require authentication
</div>
</div>
<!-- Credentials Section -->
<div id="credentials-section" style="${this.currentSettings.method !== 'none' ? '' : 'display: none;'}">
<h3>Credentials</h3>
<!-- Username -->
<div class="form-group">
<label for="username" class="form-label">Username</label>
<input type="text" id="username" class="form-input" value="${this.currentSettings.username || ''}" placeholder="Enter username">
<div class="field-description">Username for authentication</div>
</div>
<!-- Password -->
<div class="form-group">
<label for="password" class="form-label">Password</label>
<div class="password-input-group">
<input type="password" id="password" class="form-input" placeholder="Enter new password">
<button type="button" class="btn btn-icon password-toggle" data-target="password">
${EYE_ICON_SVG}
</button>
</div>
<div class="field-description">Leave blank to keep current password</div>
</div>
<!-- Confirm Password -->
<div class="form-group">
<label for="confirm-password" class="form-label">Confirm Password</label>
<div class="password-input-group">
<input type="password" id="confirm-password" class="form-input" placeholder="Confirm new password">
<button type="button" class="btn btn-icon password-toggle" data-target="confirm-password">
${EYE_ICON_SVG}
</button>
</div>
<div class="field-description">Must match the password above</div>
</div>
</div>
<!-- API Key Section -->
<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">
<input type="text" id="api-key-display" class="form-input" readonly value="${this.currentSettings.api_key || ''}" placeholder="No API key generated">
<button type="button" class="btn btn-secondary" id="generate-api-key">
${this.currentSettings.api_key ? 'Generate New Key' : 'Generate Key'}
</button>
${this.currentSettings.api_key ? `
<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.
</div>
</div>
</div>
<!-- Save Button -->
<div class="form-actions">
<button type="button" class="btn btn-primary" id="save-security-settings">
Save Settings
</button>
</div>
</div>
</div>
`;
}
bindEvents() {
// Cache DOM elements for better performance
this.authMethod = document.getElementById('auth-method');
this.localAddressesGroup = document.getElementById('local-addresses-group');
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
if (this.authMethod) {
this.authMethod.addEventListener('change', () => {
this.handleMethodChange(this.authMethod.value);
});
}
// Handle password visibility toggles
const passwordToggles = document.querySelectorAll('.password-toggle');
passwordToggles.forEach(toggle => {
toggle.addEventListener('click', (e) => {
const targetId = toggle.dataset.target;
this.togglePasswordVisibility(targetId);
});
});
// Handle generate API key
if (this.generateApiKeyBtn) {
this.generateApiKeyBtn.addEventListener('click', () => {
this.generateApiKey();
});
}
// Handle clear API key
if (this.clearApiKeyBtn) {
this.clearApiKeyBtn.addEventListener('click', () => {
this.clearApiKey();
});
}
// Handle copy API key
if (this.copyApiKeyBtn) {
this.copyApiKeyBtn.addEventListener('click', () => {
this.copyApiKey();
});
}
// Handle save settings
if (this.saveBtn) {
this.saveBtn.addEventListener('click', () => {
this.saveSettings();
});
}
}
handleMethodChange(method) {
const isNone = method === 'none';
const isApiOnly = method === 'api_only';
if (this.localAddressesGroup) {
this.localAddressesGroup.style.display = isNone ? 'none' : '';
}
if (this.credentialsSection) {
this.credentialsSection.style.display = (isNone || isApiOnly) ? 'none' : '';
}
}
togglePasswordVisibility(targetId) {
const input = document.getElementById(targetId);
const button = document.querySelector(`[data-target="${targetId}"]`);
if (!input || !button) {
console.error('Password input or toggle button not found:', targetId);
return;
}
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
button.innerHTML = isPassword ? EYE_SLASH_ICON_SVG : EYE_ICON_SVG;
}
// Removed maskApiKey and toggleApiKeyVisibility methods - API key is always shown in full
async generateApiKey() {
if (!confirm('Are you sure you want to generate a new API key? The old key will no longer work.')) {
return;
}
try {
// Get current form values to send complete request
const method = document.getElementById('auth-method').value;
const allowLocalCheckbox = document.getElementById('allow-local');
const allowLocalIps = allowLocalCheckbox && allowLocalCheckbox.checked;
const username = document.getElementById('username')?.value.trim() || '';
const password = document.getElementById('password')?.value || '';
const requestData = {
enabled: method !== 'none',
method: method,
bypass_auth_for_local: allowLocalIps,
username: username,
password: password,
generate_api_key: true
};
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;
}
// Re-render to update button visibility
this.render();
this.bindEvents();
showToast('New API key generated successfully', 'success');
} else {
showToast(response.message || 'Failed to generate API key', 'error');
}
} catch (error) {
console.error('Failed to generate API key:', error);
showToast('Failed to generate API key', 'error');
}
}
async clearApiKey() {
if (!confirm('Are you sure you want to clear the API key? This will disable API access until a new key is generated.')) {
return;
}
try {
// Get current form values to send complete request
const method = document.getElementById('auth-method').value;
const allowLocalCheckbox = document.getElementById('allow-local');
const allowLocalIps = allowLocalCheckbox && allowLocalCheckbox.checked;
const username = document.getElementById('username')?.value.trim() || '';
const password = document.getElementById('password')?.value || '';
const requestData = {
enabled: method !== 'none',
method: method,
bypass_auth_for_local: allowLocalIps,
username: username,
password: password,
clear_api_key: true
};
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
this.render();
this.bindEvents();
showToast('API key cleared successfully', 'success');
} else {
showToast(response.message || 'Failed to clear API key', 'error');
}
} catch (error) {
console.error('Failed to clear API key:', error);
showToast('Failed to clear API key', 'error');
}
}
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;
const hasUpper = /[A-Z]/.test(password);
const hasLower = /[a-z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*]/.test(password);
// Count how many character types are present
const typeCount = [hasUpper, hasLower, hasNumber, hasSpecial].filter(Boolean).length;
return password.length >= minLength && typeCount >= 3;
}
async saveSettings() {
try {
const method = document.getElementById('auth-method').value;
const allowLocalCheckbox = document.getElementById('allow-local');
const allowLocalIps = allowLocalCheckbox && allowLocalCheckbox.checked;
const username = document.getElementById('username')?.value.trim() || '';
const password = document.getElementById('password')?.value || '';
const confirmPassword = document.getElementById('confirm-password')?.value || '';
// Validation
if (method !== 'none' && method !== 'api_only') {
if (!username) {
showToast('Username is required', 'error');
return;
}
if (password && password !== confirmPassword) {
showToast('Passwords do not match', 'error');
return;
}
// Password complexity validation
if (password && !this.validatePasswordComplexity(password)) {
showToast('Password must be at least 8 characters with at least 3 of: uppercase, lowercase, number, special character', 'error');
return;
}
}
// Prepare request data
const requestData = {
enabled: method !== 'none',
method: method,
bypass_auth_for_local: allowLocalIps,
username: username,
password: password,
generate_api_key: false
};
// Make API request
const response = await this.api.makeRequest('/security', 'PUT', requestData);
if (response.success) {
showToast('Security settings saved successfully', 'success');
// Update current settings
this.currentSettings = {
enabled: method !== 'none',
method: method,
bypass_auth_for_local: allowLocalIps,
username: username,
password_hash: password ? '***' : this.currentSettings.password_hash,
api_key: this.currentSettings.api_key
};
// Clear password fields
const passwordField = document.getElementById('password');
const confirmPasswordField = document.getElementById('confirm-password');
if (passwordField) passwordField.value = '';
if (confirmPasswordField) confirmPasswordField.value = '';
} else {
showToast(response.message || 'Failed to save settings', 'error');
}
} catch (error) {
console.error('Failed to save security settings:', error);
showToast('Failed to save security settings', 'error');
}
}
async resetSettings() {
if (confirm('Are you sure you want to reset security settings? This will disable authentication.')) {
try {
await this.loadSecuritySettings();
this.render();
this.bindEvents();
showToast('Security settings reset', 'info');
} catch (error) {
console.error('Failed to reset settings:', error);
showToast('Failed to reset settings', 'error');
}
}
}
}