2FA/WebAuthn, user management, deploy and fix scripts

- loginSystem: WebAuthn (webauthn backend, models, urls, views), login template and webauthn.js
- baseTemplate: index.html updates
- docs: 2FA_AUTHENTICATION_GUIDE.md
- userManagment: createUser/modifyUser templates, userManagment.js, views, tests; check_modify_users_page.py
- requirments.txt: add webauthn>=2.0.0
- deploy-templates.sh: deploy templates/static to live CyberCP
- fix-cyberpanel-500.sh: script for common HTTP 500 login fixes (MariaDB, configservercsf, cache, restart)
This commit is contained in:
master3395
2026-03-07 02:46:15 +01:00
parent 67d8a716dc
commit dc79703463
18 changed files with 940 additions and 532 deletions

View File

@@ -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
View 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."

View File

@@ -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://207.180.193.210:2087`).
### Setting Up WebAuthn
#### Prerequisites

45
fix-cyberpanel-500.sh Normal file
View 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)."

View File

@@ -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) {

View File

@@ -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();
}
});
}

View File

@@ -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()})

View File

@@ -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")

View File

@@ -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

View File

@@ -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'),

View File

@@ -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)

View File

@@ -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

View 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()

View File

@@ -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';
}

View File

@@ -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;">

View File

@@ -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>

View File

@@ -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')

View File

@@ -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()