mirror of
https://github.com/usmannasir/cyberpanel.git
synced 2026-05-06 16:06:42 +02:00
@@ -2221,6 +2221,7 @@
|
||||
<!-- Additional Scripts (data-cfasync=false ensures controllers load before Angular compiles) -->
|
||||
<script src="{% static 'packages/packages.js' %}?v={{ CP_VERSION }}" data-cfasync="false"></script>
|
||||
<script src="{% static 'websiteFunctions/websiteFunctions.js' %}?v={{ CP_VERSION }}" data-cfasync="false"></script>
|
||||
<script src="{% static 'loginSystem/webauthn.js' %}?v={{ CP_VERSION }}" data-cfasync="false"></script>
|
||||
<script src="{% static 'userManagment/userManagment.js' %}?v={{ CP_VERSION }}" data-cfasync="false"></script>
|
||||
<script src="{% static 'databases/databases.js' %}?v={{ CP_VERSION }}" data-cfasync="false"></script>
|
||||
<script src="{% static 'dns/dns.js' %}?v={{ CP_VERSION }}&dns={{ DNS_STATIC_VERSION }}" data-cfasync="false"></script>
|
||||
|
||||
52
deploy-templates.sh
Executable file
52
deploy-templates.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# Deploy updated templates (and related static) from this repo to live CyberPanel.
|
||||
# Use after pulling template changes so the panel at /usr/local/CyberCP shows the new UI.
|
||||
# Usage: sudo bash deploy-templates.sh
|
||||
|
||||
set -e
|
||||
|
||||
CYBERCP_ROOT="${CYBERCP_ROOT:-/usr/local/CyberCP}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "[$(date +%Y-%m-%d\ %H:%M:%S)] Deploying templates to $CYBERCP_ROOT..."
|
||||
|
||||
# userManagment templates
|
||||
for name in modifyUser createUser; do
|
||||
SRC="$SCRIPT_DIR/userManagment/templates/userManagment/${name}.html"
|
||||
DST="$CYBERCP_ROOT/userManagment/templates/userManagment/${name}.html"
|
||||
if [ -f "$SRC" ]; then
|
||||
cp -f "$SRC" "$DST"
|
||||
echo " Copied userManagment ${name}.html"
|
||||
fi
|
||||
done
|
||||
|
||||
# loginSystem login template
|
||||
if [ -f "$SCRIPT_DIR/loginSystem/templates/loginSystem/login.html" ]; then
|
||||
cp -f "$SCRIPT_DIR/loginSystem/templates/loginSystem/login.html" \
|
||||
"$CYBERCP_ROOT/loginSystem/templates/loginSystem/login.html"
|
||||
echo " Copied loginSystem login.html"
|
||||
fi
|
||||
|
||||
# Optional: userManagment static (if you change JS)
|
||||
if [ -f "$SCRIPT_DIR/userManagment/static/userManagment/userManagment.js" ]; then
|
||||
mkdir -p "$CYBERCP_ROOT/userManagment/static/userManagment"
|
||||
cp -f "$SCRIPT_DIR/userManagment/static/userManagment/userManagment.js" \
|
||||
"$CYBERCP_ROOT/userManagment/static/userManagment/userManagment.js"
|
||||
echo " Copied userManagment.js"
|
||||
fi
|
||||
if [ -f "$SCRIPT_DIR/loginSystem/static/loginSystem/webauthn.js" ]; then
|
||||
mkdir -p "$CYBERCP_ROOT/loginSystem/static/loginSystem"
|
||||
cp -f "$SCRIPT_DIR/loginSystem/static/loginSystem/webauthn.js" \
|
||||
"$CYBERCP_ROOT/loginSystem/static/loginSystem/webauthn.js"
|
||||
echo " Copied webauthn.js"
|
||||
fi
|
||||
|
||||
# Run collectstatic if manage.py exists (so static changes are served)
|
||||
if [ -f "$CYBERCP_ROOT/manage.py" ]; then
|
||||
PYTHON="${CYBERCP_ROOT}/bin/python"
|
||||
[ -x "$PYTHON" ] || PYTHON="python3"
|
||||
echo " Running collectstatic..."
|
||||
(cd "$CYBERCP_ROOT" && "$PYTHON" manage.py collectstatic --noinput --clear 2>&1) | tail -5
|
||||
fi
|
||||
|
||||
echo "[$(date +%Y-%m-%d\ %H:%M:%S)] Templates deployed. Hard-refresh (Ctrl+F5) on Modify User / Create User / Login if needed."
|
||||
@@ -97,6 +97,8 @@ TOTP generates time-based codes that change every 30 seconds. Users scan a QR co
|
||||
### What is WebAuthn?
|
||||
WebAuthn is a web standard that enables secure, passwordless authentication using public-key cryptography. It supports biometric authentication, security keys, and device passkeys.
|
||||
|
||||
**Login behaviour**: The login page supports **passkey-first** sign-in: users can click "Login with Passkey" without entering a username. Passkeys are managed under **User Management → Modify User**. The relying party ID (`rp_id`) and origin are derived from the current request host only (never hardcoded), so WebAuthn works on any domain or IP (e.g. `https://your-server:2087`).
|
||||
|
||||
### Setting Up WebAuthn
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
45
fix-cyberpanel-500.sh
Normal file
45
fix-cyberpanel-500.sh
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# fix-cyberpanel-500.sh – Apply common fixes for CyberPanel HTTP 500 on login.
|
||||
# Run on the server: sudo bash fix-cyberpanel-500.sh
|
||||
# See: to-do/CYBERPANEL-HTTP-500-LOGIN-FIX.md
|
||||
|
||||
set -e
|
||||
LOG="/var/log/cyberpanel_500_fix.log"
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Run as root: sudo bash $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "=== CyberPanel 500 fix script started ==="
|
||||
|
||||
# 0. Ensure MariaDB is running (common cause: DB down -> 500)
|
||||
log "Step 0: Ensuring MariaDB is running..."
|
||||
systemctl start mariadb 2>/dev/null || systemctl start mysql 2>/dev/null || true
|
||||
systemctl enable mariadb 2>/dev/null || systemctl enable mysql 2>/dev/null || true
|
||||
log "Step 0 done."
|
||||
|
||||
# 1. Remove or neutralize configservercsf (common cause of 500)
|
||||
log "Step 1: Cleaning configservercsf references..."
|
||||
rm -rf /usr/local/CyberCP/configservercsf 2>/dev/null || true
|
||||
rm -f /home/cyberpanel/plugins/configservercsf 2>/dev/null || true
|
||||
rm -rf /usr/local/CyberCP/public/static/configservercsf 2>/dev/null || true
|
||||
sed -i '/configservercsf/d' /usr/local/CyberCP/CyberCP/settings.py 2>/dev/null || true
|
||||
sed -i '/configservercsf/d' /usr/local/CyberCP/CyberCP/urls.py 2>/dev/null || true
|
||||
log "Step 1 done."
|
||||
|
||||
# 2. Clear Python cache
|
||||
log "Step 2: Clearing __pycache__..."
|
||||
find /usr/local/CyberCP -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
log "Step 2 done."
|
||||
|
||||
# 3. Restart panel and web server
|
||||
log "Step 3: Restarting lscpd and lsws..."
|
||||
systemctl restart lscpd
|
||||
systemctl restart lsws
|
||||
killall lsphp 2>/dev/null || true
|
||||
log "Step 3 done."
|
||||
|
||||
log "=== Fix script finished. Try https://YOUR_IP:2087 or :8090 ==="
|
||||
log "If 500 persists, enable DEBUG in /usr/local/CyberCP/CyberCP/settings.py and check logs (see to-do/CYBERPANEL-HTTP-500-LOGIN-FIX.md)."
|
||||
@@ -10,6 +10,7 @@ class CyberPanelWebAuthn {
|
||||
this.apiEndpoints = {
|
||||
registrationStart: '/webauthn/registration/start/',
|
||||
registrationComplete: '/webauthn/registration/complete/',
|
||||
authenticationOptions: '/webauthn/authentication/options/',
|
||||
authenticationStart: '/webauthn/authentication/start/',
|
||||
authenticationComplete: '/webauthn/authentication/complete/',
|
||||
credentialsList: '/webauthn/credentials/',
|
||||
@@ -60,18 +61,10 @@ class CyberPanelWebAuthn {
|
||||
addLoginButtons() {
|
||||
const loginForm = document.querySelector('#loginForm');
|
||||
if (!loginForm) return;
|
||||
|
||||
// Add WebAuthn login button
|
||||
const webauthnButton = document.createElement('button');
|
||||
webauthnButton.type = 'button';
|
||||
webauthnButton.className = 'btn btn-primary btn-block';
|
||||
webauthnButton.innerHTML = '<i class="fas fa-fingerprint"></i> Login with Passkey';
|
||||
webauthnButton.onclick = () => this.startPasswordlessLogin();
|
||||
|
||||
// Insert after password field
|
||||
const passwordField = loginForm.querySelector('input[type="password"]');
|
||||
if (passwordField) {
|
||||
passwordField.parentNode.insertBefore(webauthnButton, passwordField.parentNode.nextSibling);
|
||||
const existingBtn = document.getElementById('webauthn-login-btn');
|
||||
if (existingBtn && !existingBtn.dataset.bound) {
|
||||
existingBtn.dataset.bound = '1';
|
||||
existingBtn.onclick = () => this.startPasskeyFirstLogin();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +73,76 @@ class CyberPanelWebAuthn {
|
||||
// Implementation depends on the specific UI structure
|
||||
}
|
||||
|
||||
arrayBufferToBase64url(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
base64urlToArrayBuffer(str) {
|
||||
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = (4 - (base64.length % 4)) % 4;
|
||||
for (let i = 0; i < pad; i++) base64 += '=';
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
async startPasskeyFirstLogin() {
|
||||
try {
|
||||
this.showLoading('Signing in with passkey...');
|
||||
const optsUrl = this.apiEndpoints.authenticationOptions + '?return=' + encodeURIComponent(window.location.pathname || '/');
|
||||
const optsResponse = await fetch(optsUrl, { method: 'GET', credentials: 'same-origin' });
|
||||
const optsData = await optsResponse.json();
|
||||
if (!optsData.publicKey) {
|
||||
throw new Error(optsData.error || 'Failed to get options');
|
||||
}
|
||||
const publicKey = optsData.publicKey;
|
||||
publicKey.challenge = this.base64urlToArrayBuffer(publicKey.challenge);
|
||||
if (publicKey.allowCredentials && publicKey.allowCredentials.length) {
|
||||
publicKey.allowCredentials = publicKey.allowCredentials.map(function(c) {
|
||||
return {
|
||||
type: c.type || 'public-key',
|
||||
id: typeof c.id === 'string' ? this.base64urlToArrayBuffer(c.id) : c.id,
|
||||
transports: c.transports
|
||||
};
|
||||
}.bind(this));
|
||||
}
|
||||
const credential = await navigator.credentials.get({ publicKey });
|
||||
if (!credential) throw new Error('No credential');
|
||||
const credentialJson = {
|
||||
id: credential.id,
|
||||
rawId: this.arrayBufferToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: this.arrayBufferToBase64url(credential.response.clientDataJSON),
|
||||
authenticatorData: this.arrayBufferToBase64url(credential.response.authenticatorData),
|
||||
signature: this.arrayBufferToBase64url(credential.response.signature),
|
||||
userHandle: credential.response.userHandle ? this.arrayBufferToBase64url(credential.response.userHandle) : null
|
||||
}
|
||||
};
|
||||
const authResponse = await this.makeRequest('POST', this.apiEndpoints.authenticationComplete, {
|
||||
credential: credentialJson
|
||||
});
|
||||
if (authResponse.success && authResponse.redirect) {
|
||||
window.location.href = authResponse.redirect;
|
||||
return;
|
||||
}
|
||||
throw new Error(authResponse.error || 'Verification failed');
|
||||
} catch (error) {
|
||||
if (error.name === 'NotAllowedError' || (error.message && (error.message.indexOf('cancel') !== -1 || error.message.indexOf('timed out') !== -1))) {
|
||||
this.hideLoading();
|
||||
return;
|
||||
}
|
||||
console.error('WebAuthn passkey-first error:', error);
|
||||
this.showError(error.message || 'Passkey sign-in failed');
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
async startPasswordlessLogin() {
|
||||
try {
|
||||
const username = document.querySelector('input[name="username"]').value;
|
||||
@@ -87,27 +150,13 @@ class CyberPanelWebAuthn {
|
||||
this.showError('Please enter your username first');
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLoading('Starting passkey authentication...');
|
||||
|
||||
// Get authentication challenge
|
||||
const challengeResponse = await this.makeRequest('POST', this.apiEndpoints.authenticationStart, {
|
||||
username: username
|
||||
});
|
||||
|
||||
const challengeResponse = await this.makeRequest('POST', this.apiEndpoints.authenticationStart, { username: username });
|
||||
if (!challengeResponse.success) {
|
||||
throw new Error(challengeResponse.error || 'Failed to start authentication');
|
||||
}
|
||||
|
||||
// Convert challenge to proper format
|
||||
const challenge = this.convertChallenge(challengeResponse.challenge);
|
||||
|
||||
// Get credential
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: challenge
|
||||
});
|
||||
|
||||
// Complete authentication
|
||||
const credential = await navigator.credentials.get({ publicKey: challenge });
|
||||
const authResponse = await this.makeRequest('POST', this.apiEndpoints.authenticationComplete, {
|
||||
challenge_id: challengeResponse.challenge_id,
|
||||
credential: {
|
||||
@@ -117,19 +166,14 @@ class CyberPanelWebAuthn {
|
||||
client_data_json: this.arrayBufferToBase64(credential.response.clientDataJSON),
|
||||
authenticator_data: this.arrayBufferToBase64(credential.response.authenticatorData),
|
||||
signature: this.arrayBufferToBase64(credential.response.signature),
|
||||
user_handle: credential.response.userHandle ?
|
||||
this.arrayBufferToBase64(credential.response.userHandle) : null
|
||||
user_handle: credential.response.userHandle ? this.arrayBufferToBase64(credential.response.userHandle) : null
|
||||
});
|
||||
|
||||
if (authResponse.success) {
|
||||
this.showSuccess('Authentication successful! Redirecting...');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1000);
|
||||
setTimeout(() => { window.location.href = authResponse.redirect || '/'; }, 1000);
|
||||
} else {
|
||||
throw new Error(authResponse.error || 'Authentication failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('WebAuthn authentication error:', error);
|
||||
this.showError(error.message || 'Authentication failed');
|
||||
@@ -138,9 +182,10 @@ class CyberPanelWebAuthn {
|
||||
}
|
||||
}
|
||||
|
||||
async registerPasskey(username, credentialName = '') {
|
||||
async registerPasskey(username, credentialName = '', options = {}) {
|
||||
const silent = options && options.silent === true;
|
||||
try {
|
||||
this.showLoading('Starting passkey registration...');
|
||||
if (!silent) this.showLoading('Starting passkey registration...');
|
||||
|
||||
// Get registration challenge
|
||||
const challengeResponse = await this.makeRequest('POST', this.apiEndpoints.registrationStart, {
|
||||
@@ -172,7 +217,7 @@ class CyberPanelWebAuthn {
|
||||
});
|
||||
|
||||
if (regResponse.success) {
|
||||
this.showSuccess('Passkey registered successfully!');
|
||||
if (!silent) this.showSuccess('Passkey registered successfully!');
|
||||
return regResponse;
|
||||
} else {
|
||||
throw new Error(regResponse.error || 'Registration failed');
|
||||
@@ -180,10 +225,10 @@ class CyberPanelWebAuthn {
|
||||
|
||||
} catch (error) {
|
||||
console.error('WebAuthn registration error:', error);
|
||||
this.showError(error.message || 'Registration failed');
|
||||
if (!silent) this.showError(error.message || 'Registration failed');
|
||||
throw error;
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
if (!silent) this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,23 +310,25 @@ class CyberPanelWebAuthn {
|
||||
}
|
||||
|
||||
convertChallenge(challenge) {
|
||||
// Convert base64 challenge to ArrayBuffer
|
||||
const challengeBytes = this.base64ToArrayBuffer(challenge.challenge);
|
||||
|
||||
const ch = challenge.challenge;
|
||||
const challengeBytes = (typeof ch === 'string' && (ch.indexOf('-') !== -1 || ch.indexOf('_') !== -1))
|
||||
? this.base64urlToArrayBuffer(ch) : this.base64ToArrayBuffer(ch);
|
||||
const userId = challenge.user && challenge.user.id;
|
||||
const userIdBuf = !userId ? undefined : (typeof userId === 'string' && (userId.indexOf('-') !== -1 || userId.indexOf('_') !== -1)
|
||||
? this.base64urlToArrayBuffer(userId) : this.base64ToArrayBuffer(userId));
|
||||
return {
|
||||
...challenge,
|
||||
challenge: challengeBytes,
|
||||
user: {
|
||||
...challenge.user,
|
||||
id: this.base64ToArrayBuffer(challenge.user.id)
|
||||
},
|
||||
user: challenge.user ? { ...challenge.user, id: userIdBuf } : undefined,
|
||||
excludeCredentials: challenge.excludeCredentials?.map(cred => ({
|
||||
...cred,
|
||||
id: this.base64ToArrayBuffer(cred.id)
|
||||
id: typeof cred.id === 'string' && (cred.id.indexOf('-') !== -1 || cred.id.indexOf('_') !== -1)
|
||||
? this.base64urlToArrayBuffer(cred.id) : this.base64ToArrayBuffer(cred.id)
|
||||
})) || [],
|
||||
allowCredentials: challenge.allowCredentials?.map(cred => ({
|
||||
...cred,
|
||||
id: this.base64ToArrayBuffer(cred.id)
|
||||
id: typeof cred.id === 'string' && (cred.id.indexOf('-') !== -1 || cred.id.indexOf('_') !== -1)
|
||||
? this.base64urlToArrayBuffer(cred.id) : this.base64ToArrayBuffer(cred.id)
|
||||
})) || []
|
||||
};
|
||||
}
|
||||
@@ -383,12 +430,17 @@ class CyberPanelWebAuthn {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize WebAuthn when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize WebAuthn - run now if DOM ready, else on DOMContentLoaded (script often loads after DOM is ready)
|
||||
function initCyberPanelWebAuthn() {
|
||||
if (CyberPanelWebAuthn.isSupported()) {
|
||||
window.cyberPanelWebAuthn = new CyberPanelWebAuthn();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initCyberPanelWebAuthn);
|
||||
} else {
|
||||
initCyberPanelWebAuthn();
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
|
||||
@@ -352,7 +352,8 @@
|
||||
class="btn btn-success btn-block btn-login">Sign In
|
||||
</button>
|
||||
|
||||
<!-- WebAuthn Passkey Login Button -->
|
||||
{% if passkey_login_available %}
|
||||
<!-- WebAuthn Passkey Login Button (only when at least one passkey is registered) -->
|
||||
<div id="webauthn-login-section" style="margin-top: 15px; display: none;">
|
||||
<button type="button" id="webauthn-login-btn"
|
||||
class="btn btn-primary btn-block btn-login"
|
||||
@@ -363,6 +364,7 @@
|
||||
<small class="text-muted">or</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -414,29 +416,17 @@
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// Initialize WebAuthn login functionality
|
||||
// Initialize WebAuthn login functionality (passkey-first: no username required)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const webauthnSection = document.getElementById('webauthn-login-section');
|
||||
const webauthnBtn = document.getElementById('webauthn-login-btn');
|
||||
const usernameInput = document.querySelector('input[name="username"]');
|
||||
|
||||
// Show WebAuthn section if supported
|
||||
if (window.cyberPanelWebAuthn && window.cyberPanelWebAuthn.isSupported()) {
|
||||
if (webauthnSection && webauthnBtn && window.cyberPanelWebAuthn && window.cyberPanelWebAuthn.isSupported()) {
|
||||
webauthnSection.style.display = 'block';
|
||||
|
||||
// Add click handler for WebAuthn login
|
||||
webauthnBtn.disabled = false;
|
||||
webauthnBtn.addEventListener('click', function() {
|
||||
if (window.cyberPanelWebAuthn) {
|
||||
window.cyberPanelWebAuthn.startPasswordlessLogin();
|
||||
}
|
||||
});
|
||||
|
||||
// Show/hide WebAuthn button based on username input
|
||||
usernameInput.addEventListener('input', function() {
|
||||
if (this.value.trim()) {
|
||||
webauthnBtn.disabled = false;
|
||||
} else {
|
||||
webauthnBtn.disabled = true;
|
||||
window.cyberPanelWebAuthn.startPasskeyFirstLogin();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -163,6 +163,15 @@ def verifyLogin(request):
|
||||
json_data = json.dumps(data)
|
||||
return HttpResponse(json_data)
|
||||
|
||||
def _passkey_login_enabled():
|
||||
"""Return True only when passkey login should be shown (at least one passkey registered)."""
|
||||
try:
|
||||
from .webauthn_models import WebAuthnCredential
|
||||
return WebAuthnCredential.objects.filter(is_active=True).exists()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def loadLoginPage(request):
|
||||
try:
|
||||
@@ -189,7 +198,7 @@ def loadLoginPage(request):
|
||||
# Minimal cosmetic so template does not break (login.html uses cosmetic.MainDashboardCSS)
|
||||
class _MinimalCosmetic:
|
||||
MainDashboardCSS = ''
|
||||
return render(request, 'loginSystem/login.html', {'cosmetic': _MinimalCosmetic()})
|
||||
return render(request, 'loginSystem/login.html', {'cosmetic': _MinimalCosmetic(), 'passkey_login_available': False})
|
||||
except Exception:
|
||||
return HttpResponse("Server error. Check /home/cyberpanel/error-logs.txt", status=500, content_type="text/plain")
|
||||
|
||||
@@ -293,7 +302,8 @@ def _loadLoginPage(request):
|
||||
cosmetic = CyberPanelCosmetic()
|
||||
cosmetic.save()
|
||||
|
||||
return render(request, 'loginSystem/login.html', {'cosmetic': cosmetic})
|
||||
passkey_login_available = _passkey_login_enabled()
|
||||
return render(request, 'loginSystem/login.html', {'cosmetic': cosmetic, 'passkey_login_available': passkey_login_available})
|
||||
else:
|
||||
### Load Custom CSS
|
||||
try:
|
||||
@@ -303,13 +313,21 @@ def _loadLoginPage(request):
|
||||
from baseTemplate.models import CyberPanelCosmetic
|
||||
cosmetic = CyberPanelCosmetic()
|
||||
cosmetic.save()
|
||||
return render(request, 'loginSystem/login.html', {'cosmetic': cosmetic})
|
||||
passkey_login_available = _passkey_login_enabled()
|
||||
return render(request, 'loginSystem/login.html', {'cosmetic': cosmetic, 'passkey_login_available': passkey_login_available})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def logout(request):
|
||||
try:
|
||||
del request.session['userID']
|
||||
return render(request, 'loginSystem/login.html', {})
|
||||
except:
|
||||
return render(request, 'loginSystem/login.html', {})
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
from baseTemplate.models import CyberPanelCosmetic
|
||||
cosmetic = CyberPanelCosmetic.objects.get(pk=1)
|
||||
except Exception:
|
||||
class _MinimalCosmetic:
|
||||
MainDashboardCSS = ''
|
||||
cosmetic = _MinimalCosmetic()
|
||||
return render(request, 'loginSystem/login.html', {'cosmetic': cosmetic, 'passkey_login_available': _passkey_login_enabled()})
|
||||
|
||||
@@ -1,453 +1,406 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
WebAuthn backend using webauthn library. rp_id and origin are never hardcoded;
|
||||
they are derived from the current request only.
|
||||
"""
|
||||
|
||||
import json
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
from typing import Dict, List, Optional, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .models import Administrator
|
||||
from .webauthn_models import WebAuthnCredential, WebAuthnChallenge, WebAuthnSession, WebAuthnSettings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Optional webauthn library (pip install webauthn)
|
||||
try:
|
||||
from webauthn.registration.generate_registration_options import generate_registration_options
|
||||
from webauthn.registration.verify_registration_response import verify_registration_response
|
||||
from webauthn.authentication.generate_authentication_options import generate_authentication_options
|
||||
from webauthn.authentication.verify_authentication_response import verify_authentication_response
|
||||
from webauthn.helpers import (
|
||||
options_to_json,
|
||||
base64url_to_bytes,
|
||||
bytes_to_base64url,
|
||||
)
|
||||
from webauthn.helpers.structs import (
|
||||
PublicKeyCredentialDescriptor,
|
||||
PublicKeyCredentialType,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
from webauthn.helpers.exceptions import InvalidRegistrationResponse, InvalidAuthenticationResponse
|
||||
WEBAUTHN_LIB_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBAUTHN_LIB_AVAILABLE = False
|
||||
|
||||
|
||||
def _rp_id_and_origin_from_request(request) -> tuple:
|
||||
"""Derive rp_id and origin from the current request. Never hardcode."""
|
||||
if request is None:
|
||||
raise ValueError("Request is required to derive rp_id and origin")
|
||||
host = request.get_host().split(':')[0]
|
||||
# Origin: scheme + host + port if non-default
|
||||
base_uri = request.build_absolute_uri('/')
|
||||
parsed = urlparse(base_uri)
|
||||
if (parsed.scheme == 'https' and parsed.port in (443, None)) or (parsed.scheme == 'http' and parsed.port in (80, None)):
|
||||
origin = f"{parsed.scheme}://{parsed.hostname}"
|
||||
else:
|
||||
port = parsed.port or (443 if parsed.scheme == 'https' else 80)
|
||||
origin = f"{parsed.scheme}://{parsed.hostname}:{port}"
|
||||
rp_id = host
|
||||
return rp_id, origin
|
||||
|
||||
|
||||
class WebAuthnBackend:
|
||||
"""
|
||||
WebAuthn backend for handling passkey authentication
|
||||
WebAuthn backend. rp_id and origin are derived from request only (never hardcoded).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Default WebAuthn configuration - can be overridden in Django settings
|
||||
self.rp_id = 'cyberpanel.local' # Should be your actual domain
|
||||
self.rp_name = 'CyberPanel'
|
||||
self.origin = 'https://cyberpanel.local:8090' # Should be your actual origin
|
||||
self.challenge_timeout = 300 # 5 minutes
|
||||
|
||||
|
||||
def __init__(self, request=None):
|
||||
self._request = request
|
||||
self.challenge_timeout = 300
|
||||
|
||||
def _get_rp(self, request=None):
|
||||
req = request or self._request
|
||||
rp_id, _ = _rp_id_and_origin_from_request(req)
|
||||
return rp_id, 'CyberPanel'
|
||||
|
||||
def _get_origin(self, request=None):
|
||||
_, origin = _rp_id_and_origin_from_request(request or self._request)
|
||||
return origin
|
||||
|
||||
def generate_challenge(self) -> str:
|
||||
"""Generate a random challenge"""
|
||||
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')
|
||||
|
||||
def create_registration_challenge(self, user: Administrator, credential_name: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a WebAuthn registration challenge
|
||||
"""
|
||||
"""Generate a random challenge (base64url)."""
|
||||
if WEBAUTHN_LIB_AVAILABLE:
|
||||
return bytes_to_base64url(secrets.token_bytes(32))
|
||||
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').replace('+', '-').replace('/', '_').rstrip('=')
|
||||
|
||||
def create_registration_challenge(self, user: Administrator, credential_name: str = None, request=None) -> Dict[str, Any]:
|
||||
"""Create a WebAuthn registration challenge. Uses request for rp_id/origin."""
|
||||
if not WEBAUTHN_LIB_AVAILABLE:
|
||||
return {'success': False, 'error': 'WebAuthn library not installed'}
|
||||
try:
|
||||
# Check if user has WebAuthn settings
|
||||
req = request or self._request
|
||||
if req is None:
|
||||
return {'success': False, 'error': 'Request required for registration'}
|
||||
rp_id, rp_name = self._get_rp(req)
|
||||
settings_obj = WebAuthnSettings.get_or_create_settings(user)
|
||||
|
||||
if not settings_obj.can_add_credential():
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Maximum number of credentials reached or multiple credentials not allowed'
|
||||
}
|
||||
|
||||
# Generate challenge
|
||||
challenge = self.generate_challenge()
|
||||
|
||||
# Create challenge record
|
||||
return {'success': False, 'error': 'Maximum number of credentials reached or multiple credentials not allowed'}
|
||||
exclude_creds = []
|
||||
for cred in WebAuthnCredential.objects.filter(user=user, is_active=True):
|
||||
try:
|
||||
cid = base64url_to_bytes(cred.credential_id) if cred.credential_id else None
|
||||
if cid is not None:
|
||||
exclude_creds.append(PublicKeyCredentialDescriptor(type=PublicKeyCredentialType.PUBLIC_KEY, id=cid))
|
||||
except Exception:
|
||||
pass
|
||||
options = generate_registration_options(
|
||||
rp_id=rp_id,
|
||||
rp_name=rp_name,
|
||||
user_name=user.email or user.userName,
|
||||
user_id=str(user.pk).encode(),
|
||||
user_display_name=f"{user.firstName} {user.lastName}".strip() or user.userName or None,
|
||||
timeout=settings_obj.timeout_seconds * 1000,
|
||||
exclude_credentials=exclude_creds or None,
|
||||
)
|
||||
challenge_b64 = bytes_to_base64url(options.challenge)
|
||||
challenge_obj = WebAuthnChallenge.objects.create(
|
||||
user=user,
|
||||
challenge=challenge,
|
||||
challenge=challenge_b64,
|
||||
challenge_type='registration',
|
||||
expires_at=datetime.now() + timedelta(seconds=self.challenge_timeout),
|
||||
metadata=json.dumps({
|
||||
'credential_name': credential_name or f"Passkey {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
'rp_id': self.rp_id,
|
||||
'rp_name': self.rp_name,
|
||||
})
|
||||
metadata=json.dumps({'credential_name': credential_name or f"Passkey {datetime.now().strftime('%Y-%m-%d %H:%M')}"}),
|
||||
)
|
||||
|
||||
# Create WebAuthn challenge object
|
||||
webauthn_challenge = {
|
||||
'challenge': challenge,
|
||||
'rp': {
|
||||
'id': self.rp_id,
|
||||
'name': self.rp_name,
|
||||
},
|
||||
'user': {
|
||||
'id': base64.urlsafe_b64encode(str(user.pk).encode()).decode('utf-8').rstrip('='),
|
||||
'name': user.email or user.userName,
|
||||
'displayName': f"{user.firstName} {user.lastName}".strip() or user.userName,
|
||||
},
|
||||
'pubKeyCredParams': [
|
||||
{'type': 'public-key', 'alg': -7}, # ES256
|
||||
{'type': 'public-key', 'alg': -257}, # RS256
|
||||
],
|
||||
'timeout': settings_obj.timeout_seconds * 1000,
|
||||
'attestation': 'none',
|
||||
'excludeCredentials': self._get_existing_credentials(user),
|
||||
}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'challenge': webauthn_challenge,
|
||||
'challenge_id': challenge_obj.id,
|
||||
}
|
||||
|
||||
options_dict = json.loads(options_to_json(options))
|
||||
return {'success': True, 'challenge': options_dict, 'challenge_id': challenge_obj.id}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating registration challenge: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Failed to create registration challenge: {str(e)}'
|
||||
}
|
||||
|
||||
def create_authentication_challenge(self, user: Administrator = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a WebAuthn authentication challenge
|
||||
"""
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def verify_registration(self, challenge_id: int, credential_data: Dict[str, Any],
|
||||
client_data_json: str, attestation_object: str, request=None) -> Dict[str, Any]:
|
||||
"""Verify WebAuthn registration using webauthn library."""
|
||||
if not WEBAUTHN_LIB_AVAILABLE:
|
||||
return {'success': False, 'error': 'WebAuthn library not installed'}
|
||||
try:
|
||||
# If user is specified, create user-specific challenge
|
||||
if user:
|
||||
settings_obj = WebAuthnSettings.get_or_create_settings(user)
|
||||
if not settings_obj.enabled:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'WebAuthn not enabled for this user'
|
||||
}
|
||||
|
||||
credentials = self._get_existing_credentials(user)
|
||||
if not credentials:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'No WebAuthn credentials found for this user'
|
||||
}
|
||||
else:
|
||||
# For username-based authentication, we'll need to find the user first
|
||||
credentials = []
|
||||
|
||||
# Generate challenge
|
||||
challenge = self.generate_challenge()
|
||||
|
||||
# Create challenge record
|
||||
challenge_obj = WebAuthnChallenge.objects.create(
|
||||
user=user or Administrator.objects.first(), # Fallback for username-based auth
|
||||
challenge=challenge,
|
||||
challenge_type='authentication',
|
||||
expires_at=datetime.now() + timedelta(seconds=self.challenge_timeout),
|
||||
metadata=json.dumps({
|
||||
'rp_id': self.rp_id,
|
||||
'rp_name': self.rp_name,
|
||||
})
|
||||
)
|
||||
|
||||
# Create WebAuthn challenge object
|
||||
webauthn_challenge = {
|
||||
'challenge': challenge,
|
||||
'timeout': 60000, # 1 minute
|
||||
'rpId': self.rp_id,
|
||||
'allowCredentials': credentials,
|
||||
'userVerification': 'preferred',
|
||||
}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'challenge': webauthn_challenge,
|
||||
'challenge_id': challenge_obj.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating authentication challenge: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Failed to create authentication challenge: {str(e)}'
|
||||
}
|
||||
|
||||
def verify_registration(self, challenge_id: int, credential_data: Dict[str, Any],
|
||||
client_data_json: str, attestation_object: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify WebAuthn registration response
|
||||
"""
|
||||
try:
|
||||
# Get challenge
|
||||
challenge_obj = WebAuthnChallenge.objects.get(
|
||||
id=challenge_id,
|
||||
challenge_type='registration',
|
||||
used=False
|
||||
)
|
||||
|
||||
req = request or self._request
|
||||
if req is None:
|
||||
return {'success': False, 'error': 'Request required'}
|
||||
challenge_obj = WebAuthnChallenge.objects.get(id=challenge_id, challenge_type='registration', used=False)
|
||||
if challenge_obj.is_expired():
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Challenge has expired'
|
||||
}
|
||||
|
||||
# Parse client data
|
||||
client_data = json.loads(base64.urlsafe_b64decode(client_data_json + '=='))
|
||||
|
||||
# Verify challenge
|
||||
if client_data.get('challenge') != challenge_obj.challenge:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Challenge mismatch'
|
||||
}
|
||||
|
||||
# Verify origin
|
||||
if client_data.get('origin') != self.origin:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Origin mismatch'
|
||||
}
|
||||
|
||||
# Verify type
|
||||
if client_data.get('type') != 'webauthn.create':
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Invalid response type'
|
||||
}
|
||||
|
||||
# For now, we'll do basic validation
|
||||
# In a production environment, you'd want to use a proper WebAuthn library
|
||||
# like python-webauthn or webauthn
|
||||
|
||||
# Extract credential ID and public key from attestation object
|
||||
# This is a simplified implementation
|
||||
credential_id = credential_data.get('id')
|
||||
public_key = credential_data.get('publicKey')
|
||||
|
||||
if not credential_id or not public_key:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Invalid credential data'
|
||||
}
|
||||
|
||||
# Get credential name from challenge metadata
|
||||
return {'success': False, 'error': 'Challenge has expired'}
|
||||
expected_origin = self._get_origin(req)
|
||||
expected_rp_id, _ = self._get_rp(req)
|
||||
expected_challenge = base64url_to_bytes(challenge_obj.challenge)
|
||||
credential_payload = {
|
||||
'id': credential_data.get('id'),
|
||||
'rawId': credential_data.get('rawId') or credential_data.get('id'),
|
||||
'response': {
|
||||
'clientDataJSON': client_data_json,
|
||||
'attestationObject': attestation_object,
|
||||
},
|
||||
'type': credential_data.get('type', 'public-key'),
|
||||
}
|
||||
verified = verify_registration_response(
|
||||
credential=credential_payload,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_rp_id=expected_rp_id,
|
||||
expected_origin=expected_origin,
|
||||
)
|
||||
credential_id_b64 = bytes_to_base64url(verified.credential_id)
|
||||
public_key_b64 = base64.urlsafe_b64encode(verified.credential_public_key).decode('utf-8').rstrip('=')
|
||||
metadata = challenge_obj.get_metadata()
|
||||
credential_name = metadata.get('credential_name', f"Passkey {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||||
|
||||
# Create credential record
|
||||
credential = WebAuthnCredential.objects.create(
|
||||
WebAuthnCredential.objects.create(
|
||||
user=challenge_obj.user,
|
||||
credential_id=credential_id,
|
||||
public_key=public_key,
|
||||
credential_id=credential_id_b64,
|
||||
public_key=public_key_b64,
|
||||
name=credential_name,
|
||||
counter=0
|
||||
counter=verified.sign_count,
|
||||
)
|
||||
|
||||
# Mark challenge as used
|
||||
challenge_obj.mark_used()
|
||||
|
||||
# Enable WebAuthn for user if not already enabled
|
||||
settings_obj = WebAuthnSettings.get_or_create_settings(challenge_obj.user)
|
||||
if not settings_obj.enabled:
|
||||
settings_obj.enabled = True
|
||||
settings_obj.save()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'credential_id': credential.id,
|
||||
'message': 'Passkey registered successfully'
|
||||
}
|
||||
|
||||
return {'success': True, 'message': 'Passkey registered successfully'}
|
||||
except InvalidRegistrationResponse as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
except WebAuthnChallenge.DoesNotExist:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Invalid challenge'
|
||||
}
|
||||
return {'success': False, 'error': 'Invalid challenge'}
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying registration: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Registration verification failed: {str(e)}'
|
||||
}
|
||||
|
||||
def verify_authentication(self, challenge_id: int, credential_data: Dict[str, Any],
|
||||
client_data_json: str, authenticator_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify WebAuthn authentication response
|
||||
"""
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def create_authentication_challenge(self, user: Administrator = None, request=None) -> Dict[str, Any]:
|
||||
"""Create WebAuthn authentication challenge for a specific user (username-first flow)."""
|
||||
if not WEBAUTHN_LIB_AVAILABLE:
|
||||
return {'success': False, 'error': 'WebAuthn library not installed'}
|
||||
try:
|
||||
# Get challenge
|
||||
challenge_obj = WebAuthnChallenge.objects.get(
|
||||
id=challenge_id,
|
||||
challenge_type='authentication',
|
||||
used=False
|
||||
req = request or self._request
|
||||
if req is None or user is None:
|
||||
return {'success': False, 'error': 'Request and user required'}
|
||||
settings_obj = WebAuthnSettings.get_or_create_settings(user)
|
||||
if not settings_obj.enabled:
|
||||
return {'success': False, 'error': 'WebAuthn not enabled for this user'}
|
||||
allow_creds = []
|
||||
for cred in WebAuthnCredential.objects.filter(user=user, is_active=True):
|
||||
try:
|
||||
cid = base64url_to_bytes(cred.credential_id)
|
||||
allow_creds.append(PublicKeyCredentialDescriptor(type=PublicKeyCredentialType.PUBLIC_KEY, id=cid))
|
||||
except Exception:
|
||||
pass
|
||||
if not allow_creds:
|
||||
return {'success': False, 'error': 'No WebAuthn credentials found for this user'}
|
||||
rp_id, _ = self._get_rp(req)
|
||||
options = generate_authentication_options(
|
||||
rp_id=rp_id,
|
||||
timeout=60000,
|
||||
allow_credentials=allow_creds,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
)
|
||||
|
||||
challenge_b64 = bytes_to_base64url(options.challenge)
|
||||
challenge_obj = WebAuthnChallenge.objects.create(
|
||||
user=user,
|
||||
challenge=challenge_b64,
|
||||
challenge_type='authentication',
|
||||
expires_at=datetime.now() + timedelta(seconds=self.challenge_timeout),
|
||||
metadata=json.dumps({}),
|
||||
)
|
||||
options_dict = json.loads(options_to_json(options))
|
||||
return {'success': True, 'challenge': options_dict, 'challenge_id': challenge_obj.id}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating authentication challenge: {str(e)}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def create_passkey_first_options(self, request) -> Dict[str, Any]:
|
||||
"""Create authentication options for all credentials (passkey-first login). Stores challenge in session."""
|
||||
if not WEBAUTHN_LIB_AVAILABLE:
|
||||
return {'success': False, 'error': 'WebAuthn library not installed'}
|
||||
try:
|
||||
rp_id, _ = self._get_rp(request)
|
||||
allow_creds = []
|
||||
for cred in WebAuthnCredential.objects.filter(is_active=True):
|
||||
try:
|
||||
cid = base64url_to_bytes(cred.credential_id)
|
||||
allow_creds.append(PublicKeyCredentialDescriptor(type=PublicKeyCredentialType.PUBLIC_KEY, id=cid))
|
||||
except Exception:
|
||||
pass
|
||||
if not allow_creds:
|
||||
return {'success': False, 'error': 'No passkeys registered'}
|
||||
options = generate_authentication_options(
|
||||
rp_id=rp_id,
|
||||
timeout=60000,
|
||||
allow_credentials=allow_creds,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
)
|
||||
challenge_b64 = bytes_to_base64url(options.challenge)
|
||||
request.session['webauthn_auth_challenge'] = challenge_b64
|
||||
options_dict = json.loads(options_to_json(options))
|
||||
return {'success': True, 'publicKey': options_dict}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating passkey-first options: {str(e)}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def verify_passkey_first_authentication(self, credential_payload: Dict[str, Any], request) -> Dict[str, Any]:
|
||||
"""Verify passkey-first assertion; look up user by credential id. Returns user_id on success."""
|
||||
if not WEBAUTHN_LIB_AVAILABLE:
|
||||
return {'success': False, 'error': 'WebAuthn library not installed'}
|
||||
try:
|
||||
challenge_b64 = request.session.get('webauthn_auth_challenge')
|
||||
if not challenge_b64:
|
||||
return {'success': False, 'error': 'No challenge in session'}
|
||||
raw_id = credential_payload.get('rawId') or credential_payload.get('id')
|
||||
if not raw_id:
|
||||
return {'success': False, 'error': 'No credential id'}
|
||||
cred_id_str = raw_id if isinstance(raw_id, str) else (bytes_to_base64url(bytes(raw_id)) if WEBAUTHN_LIB_AVAILABLE else str(raw_id))
|
||||
cred_id_str = cred_id_str.replace('+', '-').replace('/', '_').rstrip('=')
|
||||
try:
|
||||
cred = WebAuthnCredential.objects.get(credential_id=cred_id_str, is_active=True)
|
||||
except WebAuthnCredential.DoesNotExist:
|
||||
return {'success': False, 'error': 'Credential not found'}
|
||||
expected_origin = self._get_origin(request)
|
||||
expected_rp_id, _ = self._get_rp(request)
|
||||
expected_challenge = base64url_to_bytes(challenge_b64)
|
||||
public_key_bytes = base64.urlsafe_b64decode(cred.public_key + '==')
|
||||
auth_cred = {
|
||||
'id': credential_payload.get('id'),
|
||||
'rawId': raw_id,
|
||||
'response': credential_payload.get('response', {}),
|
||||
'type': credential_payload.get('type', 'public-key'),
|
||||
}
|
||||
verified = verify_authentication_response(
|
||||
credential=auth_cred,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_rp_id=expected_rp_id,
|
||||
expected_origin=expected_origin,
|
||||
credential_public_key=public_key_bytes,
|
||||
credential_current_sign_count=cred.counter,
|
||||
)
|
||||
cred.update_counter(verified.new_sign_count)
|
||||
if 'webauthn_auth_challenge' in request.session:
|
||||
del request.session['webauthn_auth_challenge']
|
||||
return {'success': True, 'user_id': cred.user_id, 'message': 'Authentication successful'}
|
||||
except InvalidAuthenticationResponse as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying passkey-first auth: {str(e)}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def verify_authentication(self, challenge_id: int, credential_data: Dict[str, Any],
|
||||
client_data_json: str, authenticator_data: str, request=None) -> Dict[str, Any]:
|
||||
"""Verify username-first WebAuthn authentication."""
|
||||
if not WEBAUTHN_LIB_AVAILABLE:
|
||||
return {'success': False, 'error': 'WebAuthn library not installed'}
|
||||
try:
|
||||
req = request or self._request
|
||||
if req is None:
|
||||
return {'success': False, 'error': 'Request required'}
|
||||
challenge_obj = WebAuthnChallenge.objects.get(id=challenge_id, challenge_type='authentication', used=False)
|
||||
if challenge_obj.is_expired():
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Challenge has expired'
|
||||
}
|
||||
|
||||
# Parse client data
|
||||
client_data = json.loads(base64.urlsafe_b64decode(client_data_json + '=='))
|
||||
|
||||
# Verify challenge
|
||||
if client_data.get('challenge') != challenge_obj.challenge:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Challenge mismatch'
|
||||
}
|
||||
|
||||
# Verify origin
|
||||
if client_data.get('origin') != self.origin:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Origin mismatch'
|
||||
}
|
||||
|
||||
# Verify type
|
||||
if client_data.get('type') != 'webauthn.get':
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Invalid response type'
|
||||
}
|
||||
|
||||
# Get credential
|
||||
credential_id = credential_data.get('id')
|
||||
if not credential_id:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'No credential ID provided'
|
||||
}
|
||||
|
||||
return {'success': False, 'error': 'Challenge has expired'}
|
||||
raw_id = credential_data.get('id') or credential_data.get('rawId')
|
||||
if not raw_id:
|
||||
return {'success': False, 'error': 'No credential ID provided'}
|
||||
try:
|
||||
credential = WebAuthnCredential.objects.get(
|
||||
credential_id=credential_id,
|
||||
credential_id=raw_id if isinstance(raw_id, str) else bytes_to_base64url(raw_id) if hasattr(raw_id, '__iter__') else str(raw_id),
|
||||
user=challenge_obj.user,
|
||||
is_active=True
|
||||
is_active=True,
|
||||
)
|
||||
except WebAuthnCredential.DoesNotExist:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Credential not found'
|
||||
}
|
||||
|
||||
# Verify signature (simplified - in production use proper WebAuthn library)
|
||||
# For now, we'll just update the counter and mark as successful
|
||||
|
||||
# Update credential counter
|
||||
credential.update_counter(credential.counter + 1)
|
||||
|
||||
# Mark challenge as used
|
||||
return {'success': False, 'error': 'Credential not found'}
|
||||
expected_origin = self._get_origin(req)
|
||||
expected_rp_id, _ = self._get_rp(req)
|
||||
expected_challenge = base64url_to_bytes(challenge_obj.challenge)
|
||||
public_key_bytes = base64.urlsafe_b64decode(credential.public_key + '==')
|
||||
auth_cred = {
|
||||
'id': credential_data.get('id'),
|
||||
'rawId': raw_id,
|
||||
'response': {
|
||||
'clientDataJSON': client_data_json,
|
||||
'authenticatorData': authenticator_data,
|
||||
'signature': credential_data.get('signature', ''),
|
||||
},
|
||||
'type': credential_data.get('type', 'public-key'),
|
||||
}
|
||||
verified = verify_authentication_response(
|
||||
credential=auth_cred,
|
||||
expected_challenge=expected_challenge,
|
||||
expected_rp_id=expected_rp_id,
|
||||
expected_origin=expected_origin,
|
||||
credential_public_key=public_key_bytes,
|
||||
credential_current_sign_count=credential.counter,
|
||||
)
|
||||
credential.update_counter(verified.new_sign_count)
|
||||
challenge_obj.mark_used()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'user_id': challenge_obj.user.pk,
|
||||
'credential_id': credential.id,
|
||||
'message': 'Authentication successful'
|
||||
}
|
||||
|
||||
return {'success': True, 'user_id': challenge_obj.user.pk, 'message': 'Authentication successful'}
|
||||
except InvalidAuthenticationResponse as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
except WebAuthnChallenge.DoesNotExist:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Invalid challenge'
|
||||
}
|
||||
return {'success': False, 'error': 'Invalid challenge'}
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying authentication: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Authentication verification failed: {str(e)}'
|
||||
}
|
||||
|
||||
def _get_existing_credentials(self, user: Administrator) -> List[Dict[str, Any]]:
|
||||
"""Get existing credentials for a user"""
|
||||
credentials = WebAuthnCredential.objects.filter(
|
||||
user=user,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def _get_existing_credentials(self, user: Administrator, request=None) -> List[Dict[str, Any]]:
|
||||
credentials = WebAuthnCredential.objects.filter(user=user, is_active=True)
|
||||
return [
|
||||
{
|
||||
'id': cred.credential_id,
|
||||
'type': 'public-key',
|
||||
'transports': ['internal', 'hybrid', 'usb', 'nfc', 'ble']
|
||||
}
|
||||
{'id': cred.credential_id, 'type': 'public-key', 'transports': ['internal', 'hybrid', 'usb', 'nfc', 'ble']}
|
||||
for cred in credentials
|
||||
]
|
||||
|
||||
|
||||
def get_user_credentials(self, user: Administrator) -> List[Dict[str, Any]]:
|
||||
"""Get all active credentials for a user"""
|
||||
credentials = WebAuthnCredential.objects.filter(
|
||||
user=user,
|
||||
is_active=True
|
||||
).order_by('-created_at')
|
||||
|
||||
credentials = WebAuthnCredential.objects.filter(user=user, is_active=True).order_by('-created_at')
|
||||
return [
|
||||
{
|
||||
'id': cred.id,
|
||||
'name': cred.name,
|
||||
'credential_id': cred.credential_id[:16] + '...',
|
||||
'credential_id': (cred.credential_id[:16] + '...') if len(cred.credential_id) > 16 else cred.credential_id,
|
||||
'created_at': cred.created_at.isoformat(),
|
||||
'last_used': cred.last_used.isoformat() if cred.last_used else None,
|
||||
}
|
||||
for cred in credentials
|
||||
]
|
||||
|
||||
|
||||
def delete_credential(self, user: Administrator, credential_id: int) -> Dict[str, Any]:
|
||||
"""Delete a WebAuthn credential"""
|
||||
try:
|
||||
credential = WebAuthnCredential.objects.get(
|
||||
id=credential_id,
|
||||
user=user
|
||||
)
|
||||
|
||||
credential = WebAuthnCredential.objects.get(id=credential_id, user=user)
|
||||
credential.is_active = False
|
||||
credential.save()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Credential deleted successfully'
|
||||
}
|
||||
|
||||
return {'success': True, 'message': 'Credential deleted successfully'}
|
||||
except WebAuthnCredential.DoesNotExist:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Credential not found'
|
||||
}
|
||||
return {'success': False, 'error': 'Credential not found'}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting credential: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Failed to delete credential: {str(e)}'
|
||||
}
|
||||
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def update_credential_name(self, user: Administrator, credential_id: int, new_name: str) -> Dict[str, Any]:
|
||||
"""Update credential name"""
|
||||
try:
|
||||
credential = WebAuthnCredential.objects.get(
|
||||
id=credential_id,
|
||||
user=user
|
||||
)
|
||||
|
||||
credential = WebAuthnCredential.objects.get(id=credential_id, user=user)
|
||||
credential.name = new_name
|
||||
credential.save()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Credential name updated successfully'
|
||||
}
|
||||
|
||||
return {'success': True, 'message': 'Credential name updated successfully'}
|
||||
except WebAuthnCredential.DoesNotExist:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Credential not found'
|
||||
}
|
||||
return {'success': False, 'error': 'Credential not found'}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating credential name: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Failed to update credential name: {str(e)}'
|
||||
}
|
||||
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def cleanup_expired_challenges(self):
|
||||
"""Clean up expired challenges"""
|
||||
expired_challenges = WebAuthnChallenge.objects.filter(
|
||||
expires_at__lt=datetime.now()
|
||||
)
|
||||
count = expired_challenges.count()
|
||||
expired_challenges.delete()
|
||||
expired = WebAuthnChallenge.objects.filter(expires_at__lt=datetime.now())
|
||||
count = expired.count()
|
||||
expired.delete()
|
||||
logger.info(f"Cleaned up {count} expired WebAuthn challenges")
|
||||
|
||||
|
||||
def cleanup_expired_sessions(self):
|
||||
"""Clean up expired sessions"""
|
||||
expired_sessions = WebAuthnSession.objects.filter(
|
||||
expires_at__lt=datetime.now()
|
||||
)
|
||||
count = expired_sessions.count()
|
||||
expired_sessions.delete()
|
||||
expired = WebAuthnSession.objects.filter(expires_at__lt=datetime.now())
|
||||
count = expired.count()
|
||||
expired.delete()
|
||||
logger.info(f"Cleaned up {count} expired WebAuthn sessions")
|
||||
|
||||
@@ -198,7 +198,5 @@ class WebAuthnSettings(models.Model):
|
||||
return settings
|
||||
|
||||
def can_add_credential(self):
|
||||
"""Check if user can add another credential"""
|
||||
if not self.allow_multiple_credentials:
|
||||
return WebAuthnCredential.objects.filter(user=self.user, is_active=True).count() == 0
|
||||
return WebAuthnCredential.objects.filter(user=self.user, is_active=True).count() < self.max_credentials
|
||||
"""Check if user can add another credential. No limit (like diabetes.newstargeted.com)."""
|
||||
return True
|
||||
|
||||
@@ -9,6 +9,7 @@ urlpatterns = [
|
||||
path('registration/complete/', webauthn_views.webauthn_registration_complete, name='webauthn_registration_complete'),
|
||||
|
||||
# WebAuthn Authentication
|
||||
path('authentication/options/', webauthn_views.webauthn_authentication_options, name='webauthn_authentication_options'),
|
||||
path('authentication/start/', webauthn_views.webauthn_authentication_start, name='webauthn_authentication_start'),
|
||||
path('authentication/complete/', webauthn_views.webauthn_authentication_complete, name='webauthn_authentication_complete'),
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ class WebAuthnRegistrationStart(WebAuthnAPIView):
|
||||
user.owner == current_user.pk):
|
||||
return self.error_response('Unauthorized access', 403)
|
||||
|
||||
result = self.webauthn.create_registration_challenge(user, credential_name)
|
||||
result = self.webauthn.create_registration_challenge(user, credential_name, request=request)
|
||||
return self.json_response(result)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
@@ -99,7 +99,8 @@ class WebAuthnRegistrationComplete(WebAuthnAPIView):
|
||||
challenge_id=challenge_id,
|
||||
credential_data=credential_data,
|
||||
client_data_json=client_data_json,
|
||||
attestation_object=attestation_object
|
||||
attestation_object=attestation_object,
|
||||
request=request,
|
||||
)
|
||||
|
||||
return self.json_response(result)
|
||||
@@ -128,7 +129,7 @@ class WebAuthnAuthenticationStart(WebAuthnAPIView):
|
||||
except Administrator.DoesNotExist:
|
||||
return self.error_response('User not found', 404)
|
||||
|
||||
result = self.webauthn.create_authentication_challenge(user)
|
||||
result = self.webauthn.create_authentication_challenge(user, request=request)
|
||||
return self.json_response(result)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
@@ -140,37 +141,57 @@ class WebAuthnAuthenticationStart(WebAuthnAPIView):
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class WebAuthnAuthenticationComplete(WebAuthnAPIView):
|
||||
"""Complete WebAuthn authentication process"""
|
||||
|
||||
"""Complete WebAuthn authentication (username-first or passkey-first)."""
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
challenge_id = data.get('challenge_id')
|
||||
credential_data = data.get('credential')
|
||||
if not credential_data:
|
||||
return self.error_response('Missing credential')
|
||||
|
||||
# Passkey-first: credential only, challenge in session
|
||||
challenge_id = data.get('challenge_id')
|
||||
if not challenge_id:
|
||||
result = self.webauthn.verify_passkey_first_authentication(credential_data, request)
|
||||
if result.get('success'):
|
||||
request.session['userID'] = result['user_id']
|
||||
request.session['webauthn_auth'] = True
|
||||
request.session.set_expiry(43200)
|
||||
ip_addr = request.META.get('HTTP_CF_CONNECTING_IP') or request.META.get('REMOTE_ADDR', '')
|
||||
if ip_addr.find(':') > -1:
|
||||
ip_addr = ':'.join(ip_addr.split(':')[:3])
|
||||
request.session['ipAddr'] = ip_addr
|
||||
redirect_url = data.get('redirect') or request.session.pop('webauthn_redirect', '/') or '/'
|
||||
if '//' in redirect_url or not redirect_url.startswith('/'):
|
||||
redirect_url = '/'
|
||||
result['redirect'] = redirect_url
|
||||
logger.info(f"WebAuthn passkey-first authentication successful for user ID: {result['user_id']}")
|
||||
return self.json_response(result)
|
||||
|
||||
# Username-first: challenge_id + credential parts
|
||||
client_data_json = data.get('client_data_json')
|
||||
authenticator_data = data.get('authenticator_data')
|
||||
|
||||
if not all([challenge_id, credential_data, client_data_json, authenticator_data]):
|
||||
return self.error_response('Missing required fields')
|
||||
|
||||
if not all([client_data_json, authenticator_data]):
|
||||
return self.error_response('Missing required fields for username-first auth')
|
||||
result = self.webauthn.verify_authentication(
|
||||
challenge_id=challenge_id,
|
||||
credential_data=credential_data,
|
||||
client_data_json=client_data_json,
|
||||
authenticator_data=authenticator_data
|
||||
authenticator_data=authenticator_data,
|
||||
request=request,
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
# Set session for successful authentication
|
||||
if result.get('success'):
|
||||
request.session['userID'] = result['user_id']
|
||||
request.session['webauthn_auth'] = True
|
||||
request.session.set_expiry(43200) # 12 hours
|
||||
|
||||
# Log successful authentication
|
||||
request.session.set_expiry(43200)
|
||||
ip_addr = request.META.get('HTTP_CF_CONNECTING_IP') or request.META.get('REMOTE_ADDR', '')
|
||||
if ip_addr.find(':') > -1:
|
||||
ip_addr = ':'.join(ip_addr.split(':')[:3])
|
||||
request.session['ipAddr'] = ip_addr
|
||||
logger.info(f"WebAuthn authentication successful for user ID: {result['user_id']}")
|
||||
|
||||
return self.json_response(result)
|
||||
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return self.error_response('Invalid JSON')
|
||||
except Exception as e:
|
||||
@@ -399,11 +420,43 @@ class WebAuthnCleanup(WebAuthnAPIView):
|
||||
return self.error_response(f'Internal server error: {str(e)}', 500)
|
||||
|
||||
|
||||
# Traditional function-based views for easier integration
|
||||
# Passkey-first authentication (no username): GET options
|
||||
@csrf_exempt
|
||||
@require_http_methods(["GET"])
|
||||
def webauthn_authentication_options(request):
|
||||
"""GET authentication options for all credentials (passkey-first login)."""
|
||||
try:
|
||||
backend = WebAuthnBackend(request=request)
|
||||
result = backend.create_passkey_first_options(request)
|
||||
if not result.get('success'):
|
||||
return HttpResponse(
|
||||
json.dumps(result, ensure_ascii=False),
|
||||
content_type='application/json',
|
||||
status=400,
|
||||
)
|
||||
redirect_url = request.GET.get('return', '/')
|
||||
if '//' in redirect_url or not redirect_url.startswith('/'):
|
||||
redirect_url = '/'
|
||||
request.session['webauthn_redirect'] = redirect_url
|
||||
return HttpResponse(
|
||||
json.dumps(result, ensure_ascii=False),
|
||||
content_type='application/json',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in authentication options: {str(e)}")
|
||||
return HttpResponse(
|
||||
json.dumps({'success': False, 'error': str(e)}, ensure_ascii=False),
|
||||
content_type='application/json',
|
||||
status=500,
|
||||
)
|
||||
|
||||
|
||||
# Traditional function-based views for easier integration (pass request into backend)
|
||||
@csrf_exempt
|
||||
def webauthn_registration_start(request):
|
||||
"""Start WebAuthn registration - function view"""
|
||||
view = WebAuthnRegistrationStart()
|
||||
view.webauthn = WebAuthnBackend(request=request)
|
||||
return view.post(request)
|
||||
|
||||
|
||||
@@ -411,6 +464,7 @@ def webauthn_registration_start(request):
|
||||
def webauthn_registration_complete(request):
|
||||
"""Complete WebAuthn registration - function view"""
|
||||
view = WebAuthnRegistrationComplete()
|
||||
view.webauthn = WebAuthnBackend(request=request)
|
||||
return view.post(request)
|
||||
|
||||
|
||||
@@ -418,13 +472,16 @@ def webauthn_registration_complete(request):
|
||||
def webauthn_authentication_start(request):
|
||||
"""Start WebAuthn authentication - function view"""
|
||||
view = WebAuthnAuthenticationStart()
|
||||
view.webauthn = WebAuthnBackend(request=request)
|
||||
return view.post(request)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@csrf_exempt
|
||||
def webauthn_authentication_complete(request):
|
||||
"""Complete WebAuthn authentication - function view"""
|
||||
"""Complete WebAuthn authentication - function view (username-first or passkey-first)"""
|
||||
view = WebAuthnAuthenticationComplete()
|
||||
view.webauthn = WebAuthnBackend(request=request)
|
||||
return view.post(request)
|
||||
|
||||
|
||||
|
||||
@@ -1035,6 +1035,38 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.upgrades-badge {
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
margin-left: 6px;
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upgrades-view-notice {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upgrades-view-notice i {
|
||||
color: #2563eb;
|
||||
font-size: 20px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-secondary, #f8f9ff);
|
||||
@@ -1298,6 +1330,11 @@
|
||||
<i class="fas fa-list"></i>
|
||||
{% trans "Table View" %}
|
||||
</button>
|
||||
<button class="view-btn" id="viewBtnUpgrades" onclick="toggleView('upgrades', true)">
|
||||
<i class="fas fa-arrow-circle-up"></i>
|
||||
{% trans "Upgrades Available" %}
|
||||
<span id="upgradesBadge" class="upgrades-badge" style="display: none;">0</span>
|
||||
</button>
|
||||
<button class="view-btn" onclick="toggleView('store', true)">
|
||||
<i class="fas fa-store"></i>
|
||||
{% trans "CyberPanel Plugin Store" %}
|
||||
@@ -1619,6 +1656,11 @@
|
||||
<i class="fas fa-list"></i>
|
||||
{% trans "Table View" %}
|
||||
</button>
|
||||
<button class="view-btn" id="viewBtnUpgradesNoPlugins" onclick="toggleView('upgrades', true)">
|
||||
<i class="fas fa-arrow-circle-up"></i>
|
||||
{% trans "Upgrades Available" %}
|
||||
<span id="upgradesBadgeNoPlugins" class="upgrades-badge" style="display: none;">0</span>
|
||||
</button>
|
||||
<button class="view-btn active" onclick="toggleView('store', true)">
|
||||
<i class="fas fa-store"></i>
|
||||
{% trans "CyberPanel Plugin Store" %}
|
||||
@@ -1630,6 +1672,43 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Upgrades Available (plugins with newer version in store) -->
|
||||
<div id="upgradesView" style="display: none;">
|
||||
<div class="upgrades-view-notice">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<div>
|
||||
<strong>{% trans "Upgrades Available" %}</strong>
|
||||
<p style="margin: 8px 0 0 0; color: var(--text-secondary, #64748b); font-size: 14px;">{% trans "The following installed plugins have a newer version available in the CyberPanel Plugin Store. Before upgrading, please read the about information for the release and ensure you have a current backup of your website." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="upgradesLoading" class="store-loading" style="display: none;">
|
||||
<i class="fas fa-spinner fa-spin"></i> {% trans "Checking for upgrades..." %}
|
||||
</div>
|
||||
<div id="upgradesError" class="alert alert-danger" style="display: none;">
|
||||
<i class="fas fa-exclamation-circle alert-icon"></i>
|
||||
<span id="upgradesErrorText"></span>
|
||||
</div>
|
||||
<div id="upgradesContent" style="display: block;">
|
||||
<span id="transNoUpgrades" style="display: none;">{% trans "No upgrades available. All installed plugins are up to date." %}</span>
|
||||
<div class="store-table-wrapper">
|
||||
<table class="store-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Plugin Name" %}</th>
|
||||
<th>{% trans "New Version" %}</th>
|
||||
<th>{% trans "Your Version" %}</th>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "Status / Action" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="upgradesTableBody">
|
||||
<!-- Populated by JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CyberPanel Plugin Store (always available) -->
|
||||
<div id="storeView" style="display: {% if not plugins %}block{% else %}none{% endif %};">
|
||||
<!-- Loading Indicator -->
|
||||
@@ -1801,6 +1880,7 @@ function toggleView(view, updateHash = true) {
|
||||
const gridView = document.getElementById('gridView');
|
||||
const tableView = document.getElementById('tableView');
|
||||
const storeView = document.getElementById('storeView');
|
||||
const upgradesView = document.getElementById('upgradesView');
|
||||
const viewBtns = document.querySelectorAll('.view-btn');
|
||||
|
||||
viewBtns.forEach(btn => btn.classList.remove('active'));
|
||||
@@ -1831,10 +1911,15 @@ function toggleView(view, updateHash = true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Index of view buttons: 0=Grid, 1=Table, 2=Upgrades Available, 3=Store, 4=Dev Guide (or 3 when no plugins)
|
||||
const upgradesBtnIndex = viewBtns.length >= 4 ? 2 : -1;
|
||||
const storeBtnIndex = viewBtns.length >= 4 ? 3 : 2;
|
||||
|
||||
if (view === 'grid') {
|
||||
gridView.style.display = 'grid';
|
||||
tableView.style.display = 'none';
|
||||
storeView.style.display = 'none';
|
||||
if (upgradesView) upgradesView.style.display = 'none';
|
||||
if (viewBtns[0]) viewBtns[0].classList.add('active');
|
||||
if (installedSearchWrapper) installedSearchWrapper.style.display = 'block';
|
||||
if (installedSortFilterBar) installedSortFilterBar.style.display = 'flex';
|
||||
@@ -1845,40 +1930,110 @@ function toggleView(view, updateHash = true) {
|
||||
gridView.style.display = 'none';
|
||||
tableView.style.display = 'block';
|
||||
storeView.style.display = 'none';
|
||||
if (upgradesView) upgradesView.style.display = 'none';
|
||||
if (viewBtns[1]) viewBtns[1].classList.add('active');
|
||||
if (installedSearchWrapper) installedSearchWrapper.style.display = 'block';
|
||||
if (installedSortFilterBar) installedSortFilterBar.style.display = 'flex';
|
||||
if (typeof updateInstalledSortButtons === 'function') updateInstalledSortButtons();
|
||||
if (typeof doApplyInstalledSort === 'function') doApplyInstalledSort();
|
||||
filterInstalledPlugins();
|
||||
} else if (view === 'upgrades') {
|
||||
if (installedSearchWrapper) installedSearchWrapper.style.display = 'none';
|
||||
if (installedSortFilterBar) installedSortFilterBar.style.display = 'none';
|
||||
gridView.style.display = 'none';
|
||||
tableView.style.display = 'none';
|
||||
storeView.style.display = 'none';
|
||||
if (upgradesView) upgradesView.style.display = 'block';
|
||||
if (upgradesBtnIndex >= 0 && viewBtns[upgradesBtnIndex]) viewBtns[upgradesBtnIndex].classList.add('active');
|
||||
else if (document.getElementById('viewBtnUpgrades')) document.getElementById('viewBtnUpgrades').classList.add('active');
|
||||
else if (document.getElementById('viewBtnUpgradesNoPlugins')) document.getElementById('viewBtnUpgradesNoPlugins').classList.add('active');
|
||||
|
||||
if (storePlugins.length === 0) {
|
||||
loadPluginStore(true);
|
||||
} else {
|
||||
displayUpgradesAvailable();
|
||||
}
|
||||
} else if (view === 'store') {
|
||||
if (installedSearchWrapper) installedSearchWrapper.style.display = 'none';
|
||||
if (installedSortFilterBar) installedSortFilterBar.style.display = 'none';
|
||||
gridView.style.display = 'none';
|
||||
tableView.style.display = 'none';
|
||||
if (upgradesView) upgradesView.style.display = 'none';
|
||||
storeView.style.display = 'block';
|
||||
if (viewBtns[2]) viewBtns[2].classList.add('active');
|
||||
if (storeBtnIndex >= 0 && viewBtns[storeBtnIndex]) viewBtns[storeBtnIndex].classList.add('active');
|
||||
else if (viewBtns[2]) viewBtns[2].classList.add('active');
|
||||
|
||||
// Load plugins from store if not already loaded
|
||||
if (storePlugins.length === 0) {
|
||||
loadPluginStore();
|
||||
loadPluginStore(false);
|
||||
} else {
|
||||
displayStorePlugins();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadPluginStore() {
|
||||
function updateUpgradesBadge() {
|
||||
const count = (storePlugins && storePlugins.filter(p => p.update_available).length) || 0;
|
||||
const badge = document.getElementById('upgradesBadge');
|
||||
const badgeNoPlugins = document.getElementById('upgradesBadgeNoPlugins');
|
||||
if (badge) {
|
||||
badge.textContent = String(count);
|
||||
badge.style.display = count > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
if (badgeNoPlugins) {
|
||||
badgeNoPlugins.textContent = String(count);
|
||||
badgeNoPlugins.style.display = count > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function displayUpgradesAvailable() {
|
||||
const tbody = document.getElementById('upgradesTableBody');
|
||||
const loading = document.getElementById('upgradesLoading');
|
||||
const errorDiv = document.getElementById('upgradesError');
|
||||
const errorText = document.getElementById('upgradesErrorText');
|
||||
const content = document.getElementById('upgradesContent');
|
||||
if (!tbody) return;
|
||||
|
||||
if (loading) loading.style.display = 'none';
|
||||
if (errorDiv) errorDiv.style.display = 'none';
|
||||
if (content) content.style.display = 'block';
|
||||
|
||||
const upgrades = (storePlugins || []).filter(p => p.update_available);
|
||||
if (upgrades.length === 0) {
|
||||
const noUpgradesEl = document.getElementById('transNoUpgrades');
|
||||
const noUpgradesMsg = (noUpgradesEl && noUpgradesEl.textContent) ? noUpgradesEl.textContent : 'No upgrades available. All installed plugins are up to date.';
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 24px; color: var(--text-secondary, #64748b);"><i class="fas fa-check-circle" style="color: #10b981; margin-right: 8px;"></i>' + escapeHtml(noUpgradesMsg) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
upgrades.forEach(plugin => {
|
||||
const dir = (plugin.plugin_dir || '').replace(/'/g, ''').replace(/"/g, '"');
|
||||
const name = escapeHtml(plugin.name || plugin.plugin_dir || '');
|
||||
const newVer = escapeHtml(plugin.version || '');
|
||||
const yourVer = escapeHtml(plugin.installed_version || 'Unknown');
|
||||
const date = escapeHtml(plugin.modify_date || '');
|
||||
const curVer = (plugin.installed_version || 'Unknown').replace(/'/g, ''');
|
||||
const nVer = (plugin.version || 'Unknown').replace(/'/g, ''');
|
||||
const actionHtml = '<button type="button" class="btn-action btn-upgrade" data-plugin-dir="' + dir + '" data-current-version="' + escapeHtml(curVer) + '" data-new-version="' + escapeHtml(nVer) + '" onclick="upgradePlugin(this.getAttribute(\'data-plugin-dir\'), this.getAttribute(\'data-current-version\'), this.getAttribute(\'data-new-version\'))"><i class="fas fa-arrow-up"></i> Upgrade</button>';
|
||||
html += '<tr><td><strong>' + name + '</strong></td><td>' + newVer + '</td><td>' + yourVer + '</td><td>' + date + '</td><td>' + actionHtml + '</td></tr>';
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
function loadPluginStore(forUpgradesView) {
|
||||
const storeLoading = document.getElementById('storeLoading');
|
||||
const storeError = document.getElementById('storeError');
|
||||
const storeErrorText = document.getElementById('storeErrorText');
|
||||
const storeContent = document.getElementById('storeContent');
|
||||
const storeTableBody = document.getElementById('storeTableBody');
|
||||
const upgradesLoading = document.getElementById('upgradesLoading');
|
||||
if (!storeLoading || !storeError || !storeErrorText || !storeContent) return;
|
||||
|
||||
storeLoading.style.display = 'block';
|
||||
storeError.style.display = 'none';
|
||||
storeContent.style.display = 'block';
|
||||
if (upgradesLoading) upgradesLoading.style.display = forUpgradesView ? 'block' : 'none';
|
||||
|
||||
fetch('/plugins/api/store/plugins/', {
|
||||
method: 'GET',
|
||||
@@ -1903,21 +2058,43 @@ function loadPluginStore() {
|
||||
})
|
||||
.then(data => {
|
||||
storeLoading.style.display = 'none';
|
||||
const upgradesLoadingEl = document.getElementById('upgradesLoading');
|
||||
if (upgradesLoadingEl) upgradesLoadingEl.style.display = 'none';
|
||||
|
||||
if (data.success) {
|
||||
storePlugins = data.plugins;
|
||||
displayStorePlugins();
|
||||
if (typeof updateUpgradesBadge === 'function') updateUpgradesBadge();
|
||||
const upgradesViewEl = document.getElementById('upgradesView');
|
||||
if (upgradesViewEl && upgradesViewEl.style.display === 'block' && typeof displayUpgradesAvailable === 'function') {
|
||||
displayUpgradesAvailable();
|
||||
} else {
|
||||
displayStorePlugins();
|
||||
}
|
||||
storeContent.style.display = 'block';
|
||||
} else {
|
||||
storeErrorText.textContent = data.error || 'Failed to load plugins from store';
|
||||
storeError.style.display = 'block';
|
||||
const upgradesError = document.getElementById('upgradesError');
|
||||
const upgradesErrorText = document.getElementById('upgradesErrorText');
|
||||
if (upgradesError && upgradesErrorText) {
|
||||
upgradesErrorText.textContent = data.error || 'Failed to load plugin store';
|
||||
upgradesError.style.display = 'block';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
storeLoading.style.display = 'none';
|
||||
const upgradesLoadingEl = document.getElementById('upgradesLoading');
|
||||
if (upgradesLoadingEl) upgradesLoadingEl.style.display = 'none';
|
||||
storeErrorText.textContent = error.message || 'Error loading plugin store. Please refresh the page and try again.';
|
||||
storeError.style.display = 'block';
|
||||
storeContent.style.display = 'block';
|
||||
const upgradesError = document.getElementById('upgradesError');
|
||||
const upgradesErrorText = document.getElementById('upgradesErrorText');
|
||||
if (upgradesError && upgradesErrorText) {
|
||||
upgradesErrorText.textContent = error.message || 'Error checking for upgrades.';
|
||||
upgradesError.style.display = 'block';
|
||||
}
|
||||
if (storeTableBody && storeTableBody.innerHTML === '') {
|
||||
storeTableBody.innerHTML = '<tr><td colspan="9" style="text-align: center; padding: 20px; color: var(--text-secondary, #64748b);">Unable to load plugins. Please check your connection and try again.</td></tr>';
|
||||
}
|
||||
@@ -3158,12 +3335,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Check URL hash for view preference
|
||||
const hash = window.location.hash.substring(1); // Remove #
|
||||
const validViews = ['grid', 'table', 'store'];
|
||||
const validViews = ['grid', 'table', 'upgrades', 'store'];
|
||||
|
||||
// Check if view elements exist before calling toggleView
|
||||
const gridView = document.getElementById('gridView');
|
||||
const tableView = document.getElementById('tableView');
|
||||
const storeView = document.getElementById('storeView');
|
||||
const upgradesViewEl = document.getElementById('upgradesView');
|
||||
|
||||
// Only proceed if all view elements exist (plugins are installed)
|
||||
if (gridView && tableView && storeView) {
|
||||
|
||||
@@ -38,3 +38,4 @@ python-jose==3.4.0
|
||||
websockets==15.0.1
|
||||
PyJWT>=2.10.1
|
||||
python-dotenv==1.0.0
|
||||
webauthn>=2.0.0
|
||||
|
||||
57
userManagment/check_modify_users_page.py
Normal file
57
userManagment/check_modify_users_page.py
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check that the Modify User page renders with server-side user options and search.
|
||||
Uses the real DB (no test database). Run from CyberPanel root:
|
||||
|
||||
cd /usr/local/CyberCP
|
||||
./bin/python manage.py shell < userManagment/check_modify_users_page.py
|
||||
|
||||
Or from repo:
|
||||
cd /home/cyberpanel-repo && python3 manage.py shell < userManagment/check_modify_users_page.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
|
||||
if 'django' not in sys.modules:
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'CyberCP.settings')
|
||||
import django
|
||||
django.setup()
|
||||
|
||||
from django.test import RequestFactory
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from userManagment.views import modifyUsers
|
||||
from loginSystem.models import Administrator
|
||||
|
||||
def main():
|
||||
rf = RequestFactory()
|
||||
req = rf.get('/users/modifyUsers')
|
||||
SessionMiddleware(lambda r: None).process_request(req)
|
||||
req.session.save()
|
||||
admin = Administrator.objects.first()
|
||||
if not admin:
|
||||
print('FAIL: No administrator in database')
|
||||
sys.exit(1)
|
||||
req.session['userID'] = admin.pk
|
||||
req.session.save()
|
||||
response = modifyUsers(req)
|
||||
html = response.content.decode('utf-8')
|
||||
errors = []
|
||||
if 'data-acct=' not in html:
|
||||
errors.append('missing data-acct in options')
|
||||
if 'modifyUserSearchInput' not in html:
|
||||
errors.append('missing modifyUserSearchInput')
|
||||
if 'modifyUserAccountSelect' not in html:
|
||||
errors.append('missing modifyUserAccountSelect')
|
||||
if not re.search(r'<option\s+value="[^"]+"\s+data-acct=', html):
|
||||
errors.append('no user option with value and data-acct')
|
||||
if errors:
|
||||
print('FAIL:', '; '.join(errors))
|
||||
sys.exit(1)
|
||||
print('OK: Modify User page renders user dropdown and search.')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
else:
|
||||
# When run via: manage.py shell < userManagment/check_modify_users_page.py
|
||||
main()
|
||||
@@ -171,14 +171,28 @@ app.controller('createUserCtr', function ($scope, $http) {
|
||||
|
||||
|
||||
/* Java script code to modify user account */
|
||||
app.controller('modifyUser', function ($scope, $http) {
|
||||
app.controller('modifyUser', function ($scope, $http, $timeout) {
|
||||
|
||||
var qrCode = window.qr = new QRious({
|
||||
element: document.getElementById('qr'),
|
||||
var qrEl = document.getElementById('qr');
|
||||
var qrCode = window.qr = (qrEl && typeof QRious !== 'undefined') ? new QRious({
|
||||
element: qrEl,
|
||||
size: 200,
|
||||
value: 'QRious'
|
||||
});
|
||||
}) : null;
|
||||
if (!qrCode && qrEl) {
|
||||
try { window.qr = new QRious({ element: qrEl, size: 200, value: 'QRious' }); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
$scope.userSearch = '';
|
||||
/* Prefer global set by inline script (before Angular); fallback to script tag */
|
||||
var list = (typeof window.__CP_ACCT_NAMES !== 'undefined' && Array.isArray(window.__CP_ACCT_NAMES))
|
||||
? window.__CP_ACCT_NAMES
|
||||
: (function() {
|
||||
var el = document.getElementById('acctNamesData');
|
||||
if (!el || !el.textContent) return [];
|
||||
try { return JSON.parse(el.textContent); } catch (e) { return []; }
|
||||
})();
|
||||
$scope.acctNamesList = Array.isArray(list) ? list : [];
|
||||
|
||||
$scope.userModificationLoading = true;
|
||||
$scope.acctDetailsFetched = true;
|
||||
@@ -253,9 +267,7 @@ app.controller('modifyUser', function ($scope, $http) {
|
||||
$scope.formattedSecretKey = response.data.secretKey.match(/.{1,4}/g).join(' ');
|
||||
|
||||
// Update the QR code with new provisioning URI
|
||||
qrCode.set({
|
||||
value: response.data.otpauth
|
||||
});
|
||||
if (qrCode) qrCode.set({ value: response.data.otpauth });
|
||||
|
||||
// Show success message
|
||||
alert('2FA secret has been successfully regenerated! Please update your authenticator app with the new QR code or secret key.');
|
||||
@@ -271,20 +283,24 @@ app.controller('modifyUser', function ($scope, $http) {
|
||||
// WebAuthn Functions
|
||||
$scope.loadWebAuthnData = function() {
|
||||
if (!$scope.accountUsername) return;
|
||||
|
||||
$scope.webauthnDataLoaded = false;
|
||||
var url = '/webauthn/credentials/' + $scope.accountUsername + '/';
|
||||
|
||||
$http.get(url).then(function(response) {
|
||||
if (response.data.success) {
|
||||
$scope.webauthnCredentials = response.data.credentials;
|
||||
$scope.webauthnCredentials = response.data.credentials || [];
|
||||
$scope.webauthnEnabled = response.data.settings.enabled;
|
||||
$scope.webauthnRequirePasskey = response.data.settings.require_passkey;
|
||||
$scope.webauthnAllowMultiple = response.data.settings.allow_multiple_credentials;
|
||||
$scope.webauthnMaxCredentials = response.data.settings.max_credentials;
|
||||
$scope.canAddCredential = response.data.settings.can_add_credential;
|
||||
$scope.canAddCredential = !!response.data.settings.can_add_credential;
|
||||
$scope.webauthnDataLoaded = true;
|
||||
} else {
|
||||
$scope.canAddCredential = true;
|
||||
}
|
||||
}, function(error) {
|
||||
console.error('Error loading WebAuthn data:', error);
|
||||
$scope.canAddCredential = true;
|
||||
$scope.webauthnCredentials = [];
|
||||
});
|
||||
};
|
||||
|
||||
@@ -294,27 +310,59 @@ app.controller('modifyUser', function ($scope, $http) {
|
||||
} else {
|
||||
$scope.webauthnCredentials = [];
|
||||
$scope.canAddCredential = true;
|
||||
$scope.webauthnDataLoaded = false;
|
||||
}
|
||||
};
|
||||
|
||||
/* Inline passkey UX like diabetes.newstargeted.com/profile?tab=2fa: name input + button + message area, no modal */
|
||||
$scope.newPasskeyName = '';
|
||||
$scope.webauthnMessage = '';
|
||||
$scope.webauthnMessageError = false;
|
||||
$scope.registerPasskeyLoading = false;
|
||||
|
||||
$scope.registerNewPasskey = function() {
|
||||
if (!window.cyberPanelWebAuthn) {
|
||||
alert('WebAuthn is not supported in this browser');
|
||||
if (!$scope.accountUsername) {
|
||||
$scope.webauthnMessage = 'Please select a user account first.';
|
||||
$scope.webauthnMessageError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var credentialName = prompt('Enter a name for this passkey:', 'Passkey ' + new Date().toLocaleDateString());
|
||||
if (!credentialName) return;
|
||||
|
||||
window.cyberPanelWebAuthn.registerPasskey($scope.accountUsername, credentialName)
|
||||
if (typeof window.cyberPanelWebAuthn === 'undefined') {
|
||||
$scope.webauthnMessage = 'WebAuthn script not loaded. Refresh the page (Ctrl+F5) and try again.';
|
||||
$scope.webauthnMessageError = true;
|
||||
return;
|
||||
}
|
||||
if (typeof window.cyberPanelWebAuthn.isSupported !== 'function' || !window.cyberPanelWebAuthn.isSupported()) {
|
||||
$scope.webauthnMessage = 'WebAuthn is not supported in this browser.';
|
||||
$scope.webauthnMessageError = true;
|
||||
return;
|
||||
}
|
||||
var name = ($scope.newPasskeyName || '').trim() || 'Security key';
|
||||
$scope.webauthnMessage = '';
|
||||
$scope.webauthnMessageError = false;
|
||||
$scope.registerPasskeyLoading = true;
|
||||
var username = $scope.accountUsername;
|
||||
window.cyberPanelWebAuthn.registerPasskey(username, name, { silent: true })
|
||||
.then(function(response) {
|
||||
if (response.success) {
|
||||
$scope.loadWebAuthnData();
|
||||
$scope.$apply();
|
||||
if (response && response.success) {
|
||||
$scope.webauthnMessage = 'Passkey registered successfully.';
|
||||
$scope.webauthnMessageError = false;
|
||||
$scope.newPasskeyName = '';
|
||||
$timeout(function() { $scope.loadWebAuthnData(); }, 0);
|
||||
} else {
|
||||
$scope.webauthnMessage = (response && response.error) ? response.error : 'Registration failed.';
|
||||
$scope.webauthnMessageError = true;
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
var msg = (error && error.message) ? error.message : 'Passkey registration failed.';
|
||||
if (error && error.name === 'NotAllowedError') msg = 'Registration was cancelled or timed out.';
|
||||
$scope.webauthnMessage = msg;
|
||||
$scope.webauthnMessageError = true;
|
||||
console.error('Error registering passkey:', error);
|
||||
})
|
||||
.finally(function() {
|
||||
$scope.registerPasskeyLoading = false;
|
||||
if (!$scope.$$phase && !$scope.$root.$$phase) { try { $scope.$apply(); } catch (e) { /* already applied */ } }
|
||||
});
|
||||
};
|
||||
|
||||
@@ -435,14 +483,11 @@ app.controller('modifyUser', function ($scope, $http) {
|
||||
$scope.webauthnTimeout = 60;
|
||||
$scope.webauthnCredentials = [];
|
||||
$scope.canAddCredential = true;
|
||||
|
||||
$scope.webauthnDataLoaded = false;
|
||||
// Load WebAuthn settings and credentials
|
||||
$scope.loadWebAuthnData();
|
||||
|
||||
qrCode.set({
|
||||
value: userDetails.otpauth
|
||||
});
|
||||
|
||||
if (qrCode) qrCode.set({ value: userDetails.otpauth });
|
||||
|
||||
$scope.userModificationLoading = true;
|
||||
$scope.acctDetailsFetched = false;
|
||||
@@ -537,8 +582,8 @@ app.controller('modifyUser', function ($scope, $http) {
|
||||
|
||||
$http.post(url, data, config).then(function(response) {
|
||||
ListInitialDatas(response);
|
||||
// Save WebAuthn settings after successful user modification
|
||||
if (response.data.saveStatus == 1) {
|
||||
// Save WebAuthn settings after successful user modification (only if WebAuthn script loaded)
|
||||
if (response.data.saveStatus == 1 && window.cyberPanelWebAuthn) {
|
||||
$scope.saveWebAuthnSettings();
|
||||
}
|
||||
}, cantLoadInitialDatas);
|
||||
@@ -554,7 +599,7 @@ app.controller('modifyUser', function ($scope, $http) {
|
||||
$scope.userModified = false;
|
||||
$scope.canotModifyUser = false; // hide modify error on success
|
||||
$scope.couldNotConnect = true;
|
||||
$scope.canotFetchDetails = true;
|
||||
$scope.canotFetchDetails = false; // hide "Cannot fetch details" on save success
|
||||
$scope.detailsFetched = true;
|
||||
$scope.userAccountsLimit = true;
|
||||
$scope.accountTypeView = true;
|
||||
@@ -592,7 +637,7 @@ app.controller('modifyUser', function ($scope, $http) {
|
||||
$scope.couldNotConnect = false;
|
||||
$scope.canotFetchDetails = true;
|
||||
$scope.detailsFetched = true;
|
||||
|
||||
$scope.errorMessage = (response && response.data && (response.data.error_message || response.data.message || response.data.errorMessage)) || 'Unknown error';
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -342,6 +342,11 @@
|
||||
class="form-control">
|
||||
</select>
|
||||
<p class="help-text">{% trans "Choose the security level for this account" %}</p>
|
||||
<div class="mt-2 p-3" style="font-size: 0.9rem; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; color: #212529;">
|
||||
<strong class="d-block mb-2">{% trans "What's the difference?" %}</strong>
|
||||
<strong>{% trans "High" %}:</strong> {% trans "Session is tied to the IP address used at login. If the user logs in from one IP and later visits from another (e.g. different network, VPN, or mobile), the session is invalidated and they must log in again." %}<br><br>
|
||||
<strong>{% trans "Low" %}:</strong> {% trans "Session is not tied to IP. The user can stay logged in when their IP changes (e.g. switching networks or using VPN). More convenient but slightly less strict against session reuse from another location." %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 2rem;">
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% block content %}
|
||||
|
||||
{% load static %}
|
||||
<script src="{% static 'loginSystem/webauthn.js' %}?v={{ CP_VERSION }}" data-cfasync="false"></script>
|
||||
<script src="{% static 'userManagment/userManagment.js' %}?v={{ CP_VERSION }}" data-cfasync="false"></script>
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
<!-- Current language: {{ LANGUAGE_CODE }} -->
|
||||
@@ -234,14 +235,30 @@
|
||||
<form name="modifyUserForm" action="/">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{% trans "Select User Account" %}</label>
|
||||
<select ng-change="fetchUserDetails()" ng-model="accountUsername" class="form-control">
|
||||
<input type="text" id="modifyUserSearchInput" class="form-control" placeholder="{% trans 'Search users...' %}" style="margin-bottom: 8px;" autocomplete="off">
|
||||
<select id="modifyUserAccountSelect" ng-change="fetchUserDetails()" ng-model="accountUsername" class="form-control">
|
||||
<option value="">-- {% trans "Choose a user to modify" %} --</option>
|
||||
{% for accts in acctNames %}
|
||||
<option>{{ accts }}</option>
|
||||
{% for acct in acctNames %}
|
||||
<option value="{{ acct|escapejs }}" data-acct="{{ acct|escapejs }}">{{ acct|escapejs }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="help-text">{% trans "Select the user account you want to modify" %}</p>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var searchEl = document.getElementById('modifyUserSearchInput');
|
||||
var selectEl = document.getElementById('modifyUserAccountSelect');
|
||||
if (!searchEl || !selectEl) return;
|
||||
searchEl.addEventListener('input', function() {
|
||||
var q = (searchEl.value || '').toLowerCase().trim();
|
||||
var opts = selectEl.querySelectorAll('option[data-acct]');
|
||||
for (var i = 0; i < opts.length; i++) {
|
||||
var text = (opts[i].getAttribute('data-acct') || '').toLowerCase();
|
||||
opts[i].style.display = text.indexOf(q) !== -1 ? '' : 'none';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<div ng-hide="acctDetailsFetched">
|
||||
<div class="form-row">
|
||||
<div class="form-col-6">
|
||||
@@ -294,6 +311,11 @@
|
||||
class="form-control">
|
||||
</select>
|
||||
<p class="help-text">{% trans "Choose the security level for this account" %}</p>
|
||||
<div class="mt-2 p-3" style="font-size: 0.9rem; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; color: #212529;">
|
||||
<strong class="d-block mb-2">{% trans "What's the difference?" %}</strong>
|
||||
<strong>{% trans "High" %}:</strong> {% trans "Session is tied to the IP address used at login. If the user logs in from one IP and later visits from another (e.g. different network, VPN, or mobile), the session is invalidated and they must log in again." %}<br><br>
|
||||
<strong>{% trans "Low" %}:</strong> {% trans "Session is not tied to IP. The user can stay logged in when their IP changes (e.g. switching networks or using VPN). More convenient but slightly less strict against session reuse from another location." %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{% trans "Additional Features" %}</label>
|
||||
@@ -352,27 +374,7 @@
|
||||
<p class="help-text mb-2">{% trans "When enabled, users must use passkeys to login (password becomes optional)" %}</p>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-wrapper">
|
||||
<label>
|
||||
<input ng-model="webauthnAllowMultiple" type="checkbox">
|
||||
{% trans "Allow Multiple Passkeys" %}
|
||||
</label>
|
||||
<p class="help-text mb-2">{% trans "Allow users to register multiple passkeys for backup access" %}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{% trans "Maximum Passkeys" %}</label>
|
||||
<input type="number" class="form-control" ng-model="webauthnMaxCredentials" min="1" max="20" style="max-width: 150px;">
|
||||
<p class="help-text">{% trans "Maximum number of passkeys allowed per user" %}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{% trans "Passkey Timeout (seconds)" %}</label>
|
||||
<input type="number" class="form-control" ng-model="webauthnTimeout" min="30" max="300" style="max-width: 150px;">
|
||||
<p class="help-text">{% trans "How long to wait for passkey interaction" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Passkey Management -->
|
||||
<!-- Passkey Management (no max/timeout like diabetes.newstargeted.com) -->
|
||||
<div class="mt-4">
|
||||
<h5>{% trans "Manage Passkeys" %}</h5>
|
||||
<div ng-show="webauthnCredentials.length === 0" class="alert alert-info">
|
||||
@@ -407,21 +409,136 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-primary" ng-click="registerNewPasskey()" ng-disabled="!canAddCredential">
|
||||
<i class="fa fa-plus"></i> {% trans "Register New Passkey" %}
|
||||
<p id="webauthn-msg" ng-show="webauthnMessage" ng-class="webauthnMessageError ? 'text-danger' : 'text-success'" class="mb-2" style="margin-top: 0.5rem;" ng-bind="webauthnMessage"></p>
|
||||
<div id="webauthn-msg-js" style="display:none; margin-top: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 8px;"></div>
|
||||
<div class="mt-3" style="display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;">
|
||||
<label for="webauthn-name-input" class="mb-0" style="margin-right: 0.25rem;">{% trans "Passkey name" %}:</label>
|
||||
<input type="text" id="webauthn-name-input" class="form-control" ng-model="newPasskeyName" placeholder="{% trans 'e.g. My laptop, Phone' %}" style="max-width: 240px;">
|
||||
<button type="button" id="btnRegisterPasskey" class="btn btn-primary">
|
||||
<i class="fa fa-plus"></i> <span id="btnRegisterPasskeyText">{% trans "Register New Passkey" %}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" ng-click="refreshCredentials()">
|
||||
<i class="fa fa-refresh"></i> {% trans "Refresh" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ng-show="!canAddCredential" class="alert alert-warning mt-2">
|
||||
<i class="fa fa-exclamation-triangle"></i> {% trans "Maximum number of passkeys reached" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Diabetes-style vanilla JS passkey registration (works without Angular) -->
|
||||
<script>
|
||||
(function() {
|
||||
function getCsrfToken() {
|
||||
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||
return m ? m[1].trim() : '';
|
||||
}
|
||||
function b64url(buf) {
|
||||
var b = new Uint8Array(buf);
|
||||
var s = '';
|
||||
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
function b64urlToBuffer(str) {
|
||||
var pad = (4 - (str.length % 4)) % 4;
|
||||
for (var i = 0; i < pad; i++) str += '=';
|
||||
var base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
var bin = atob(base64);
|
||||
var u = new Uint8Array(bin.length);
|
||||
for (var i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
return u.buffer;
|
||||
}
|
||||
function prepareCreateOptions(data) {
|
||||
if (!data || !data.challenge) return data;
|
||||
var pk = data.challenge;
|
||||
if (typeof pk.challenge === 'string') pk.challenge = b64urlToBuffer(pk.challenge);
|
||||
if (pk.user && typeof pk.user.id === 'string') pk.user.id = b64urlToBuffer(pk.user.id);
|
||||
if (pk.excludeCredentials && pk.excludeCredentials.length) {
|
||||
pk.excludeCredentials = pk.excludeCredentials.map(function(c) {
|
||||
return { type: c.type || 'public-key', id: typeof c.id === 'string' ? b64urlToBuffer(c.id) : c.id };
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
function showMsg(text, isError) {
|
||||
var el = document.getElementById('webauthn-msg-js');
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.style.display = text ? 'block' : 'none';
|
||||
el.className = isError ? 'alert alert-danger' : 'alert alert-success';
|
||||
}
|
||||
function runRegister() {
|
||||
var btn = document.getElementById('btnRegisterPasskey');
|
||||
var btnText = document.getElementById('btnRegisterPasskeyText');
|
||||
var nameInput = document.getElementById('webauthn-name-input');
|
||||
var selectEl = document.getElementById('modifyUserAccountSelect');
|
||||
if (!btn || !selectEl) return;
|
||||
var username = (selectEl.value || '').trim();
|
||||
var name = (nameInput && nameInput.value) ? nameInput.value.trim() : '';
|
||||
if (!name) name = 'Security key';
|
||||
if (!username) {
|
||||
showMsg('{% trans "Please select a user account first." %}', true);
|
||||
return;
|
||||
}
|
||||
if (!window.PublicKeyCredential || !navigator.credentials || !navigator.credentials.create) {
|
||||
showMsg('{% trans "WebAuthn is not supported in this browser." %}', true);
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
if (btnText) btnText.textContent = '{% trans "Loading..." %}';
|
||||
showMsg('', false);
|
||||
var csrf = getCsrfToken();
|
||||
fetch('/webauthn/registration/start/', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf },
|
||||
body: JSON.stringify({ username: username, credential_name: name })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start registration');
|
||||
if (!data.challenge) throw new Error('Invalid options');
|
||||
prepareCreateOptions(data);
|
||||
var pk = data.challenge;
|
||||
return navigator.credentials.create({ publicKey: pk }).then(function(cred) {
|
||||
if (!cred) throw new Error('No credential');
|
||||
return { cred: cred, challenge_id: data.challenge_id };
|
||||
});
|
||||
})
|
||||
.then(function(result) {
|
||||
var cred = result.cred;
|
||||
var challengeId = result.challenge_id;
|
||||
var body = JSON.stringify({
|
||||
challenge_id: challengeId,
|
||||
credential: { id: b64url(cred.rawId), type: cred.type },
|
||||
client_data_json: b64url(cred.response.clientDataJSON),
|
||||
attestation_object: b64url(cred.response.attestationObject)
|
||||
});
|
||||
return fetch('/webauthn/registration/complete/', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
|
||||
body: body
|
||||
});
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showMsg('{% trans "Passkey registered successfully." %}', false);
|
||||
if (nameInput) nameInput.value = '';
|
||||
window.location.reload();
|
||||
} else throw new Error(data.error || 'Registration failed');
|
||||
})
|
||||
.catch(function(e) {
|
||||
var msg = e.message || '{% trans "Passkey registration failed." %}';
|
||||
if (e.name === 'NotAllowedError') msg = '{% trans "Registration was cancelled or timed out." %}';
|
||||
showMsg(msg, true);
|
||||
btn.disabled = false;
|
||||
if (btnText) btnText.textContent = '{% trans "Register New Passkey" %}';
|
||||
});
|
||||
}
|
||||
var btn = document.getElementById('btnRegisterPasskey');
|
||||
if (btn) btn.addEventListener('click', runRegister);
|
||||
})();
|
||||
</script>
|
||||
<div class="form-group" style="margin-top: 2rem;">
|
||||
<button type="button" ng-click="modifyUser()" class="btn-primary">
|
||||
<i class="fa fa-save"></i> {% trans "Save Changes" %}
|
||||
@@ -437,10 +554,10 @@
|
||||
<div ng-cloak ng-show="couldNotConnect === false" class="alert alert-danger">
|
||||
<i class="fa fa-times-circle"></i> {% trans "Could not connect to server. Please refresh this page." %}
|
||||
</div>
|
||||
<div ng-cloak ng-show="canotFetchDetails === true" class="alert alert-danger">
|
||||
<div ng-cloak ng-show="canotFetchDetails === true && userModified !== false" class="alert alert-danger">
|
||||
<i class="fa fa-exclamation-circle"></i> {% trans "Cannot fetch details. Error message:" %} <span ng-bind="errorMessage || 'Unknown error'"></span>
|
||||
</div>
|
||||
<div ng-cloak ng-show="detailsFetched === true" class="alert alert-success">
|
||||
<div ng-cloak ng-show="detailsFetched === true && userModified !== false" class="alert alert-success">
|
||||
<i class="fa fa-info-circle"></i> {% trans "User details loaded successfully." %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -258,6 +258,19 @@ class TestUserManagement(TestCase):
|
||||
|
||||
self.assertEqual(Administrator.objects.get(userName='usman').api, 1)
|
||||
|
||||
def test_modifyUsers_page_shows_user_dropdown_and_search(self):
|
||||
"""Modify User page must render server-side user options and search input."""
|
||||
modify_url = reverse('modifyUsers')
|
||||
response = self.client.get(modify_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.content.decode('utf-8')
|
||||
# Server-rendered options: at least one user option (admin exists from login)
|
||||
self.assertIn('data-acct=', html, 'Template should render option with data-acct for filtering')
|
||||
self.assertIn('modifyUserSearchInput', html, 'Search input id must be present')
|
||||
self.assertIn('modifyUserAccountSelect', html, 'Select id must be present')
|
||||
# Should have at least one real option (value not empty)
|
||||
self.assertRegex(html, r'<option\s+value="[^"]+"\s+data-acct=', 'At least one user option must be rendered')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -379,8 +379,9 @@ def submitUserCreation(request):
|
||||
def modifyUsers(request):
|
||||
userID = request.session['userID']
|
||||
userNames = ACLManager.loadAllUsers(userID)
|
||||
acctNamesJson = json.dumps(list(userNames))
|
||||
proc = httpProc(request, 'userManagment/modifyUser.html',
|
||||
{"acctNames": userNames, 'securityLevels': SecurityLevel.list()})
|
||||
{"acctNames": userNames, "acctNamesJson": acctNamesJson, 'securityLevels': SecurityLevel.list()})
|
||||
return proc.render()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user