Align client auth with backend: API tokens, session login, /api/auth/me
Rework the FreeCAD client authentication to match the Silo backend's
actual auth implementation (local, LDAP, OIDC/Keycloak, API tokens).
Auth model change:
- Remove the old AuthToken/AuthTokenExpiry session-token approach
- Unify on API tokens (ApiToken preference / SILO_API_TOKEN env var)
as the single auth mechanism for the desktop client
- _get_auth_token() now delegates to _get_api_token() directly
- All requests use Bearer token auth via _get_auth_headers()
SiloClient.login() rewrite:
- POST form-encoded credentials to /login (matching the backend's
form-based login handler, works with local and LDAP backends)
- Use the resulting session cookie to call GET /api/auth/me to get
user info (username, role, auth_source)
- Create a persistent API token via POST /api/auth/tokens named
'FreeCAD (hostname)' with 90-day expiry
- Store the raw token in ApiToken preference for all future requests
- No more ephemeral session tokens — API tokens survive restarts
New SiloClient methods:
- get_current_user(): GET /api/auth/me, returns user dict or None
- refresh_auth_info(): fetches /api/auth/me and updates cached prefs
- auth_role(): returns stored role (admin/editor/viewer)
- auth_source(): returns stored auth source (local/ldap/oidc)
- list_tokens(): GET /api/auth/tokens
- create_token(name, expires_in_days): POST /api/auth/tokens
- revoke_token(token_id): DELETE /api/auth/tokens/{id}
New preference keys:
- AuthRole: cached user role from server
- AuthSource: cached auth source (local, ldap, oidc)
Removed preference keys:
- AuthToken: replaced by ApiToken (was duplicative)
- AuthTokenExpiry: API tokens have server-side expiry
Auth helper changes:
- _save_auth_info(): stores username, role, source, and optionally token
- _clear_auth(): clears ApiToken, AuthUsername, AuthRole, AuthSource
- _get_auth_role(), _get_auth_source(): new accessors
Dock widget updates:
- New Role row showing role and auth source (e.g. 'editor (ldap)')
- Status refresh validates token against /api/auth/me on each poll
- Four status states: Connected (green), Token invalid (orange),
Connected no auth (yellow), Disconnected (red)
- Caches validated user info back to preferences
Login dialog updates:
- Info text explains the flow (creates persistent API token)
- Placeholder text updated (removed LDAP-specific wording)
- Shows 'Logging in...' status during auth
- Displays role and auth source on success
- Disables login button during request
Settings dialog updates:
- API Token input field with show/hide toggle
- Token can be pasted directly from Silo web UI
- Hint text explains token sources (web UI, Login, env var)
- 'Clear Token and Logout' button replaces old clear credentials
- Save handler persists token changes
- Status summary shows role and auth source
check_connection() fix:
- Uses origin /health (not /api/health) matching actual route
Fixes the endpoint mismatch where client was calling a non-existent
POST /api/auth/login JSON endpoint. Now uses the actual form-based
POST /login + session cookie flow that the backend implements.
This commit is contained in:
@@ -91,23 +91,11 @@ def _get_ssl_context() -> ssl.SSLContext:
|
|||||||
|
|
||||||
|
|
||||||
def _get_auth_token() -> str:
|
def _get_auth_token() -> str:
|
||||||
"""Get stored auth token from preferences, checking expiry."""
|
"""Get the active API token for authenticating requests.
|
||||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
|
||||||
token = param.GetString("AuthToken", "")
|
|
||||||
if not token:
|
|
||||||
return ""
|
|
||||||
expiry = param.GetString("AuthTokenExpiry", "")
|
|
||||||
if expiry:
|
|
||||||
try:
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
exp_dt = datetime.fromisoformat(expiry.replace("Z", "+00:00"))
|
Priority: ApiToken preference > SILO_API_TOKEN env var.
|
||||||
if datetime.now(timezone.utc) >= exp_dt:
|
"""
|
||||||
_clear_auth()
|
return _get_api_token()
|
||||||
return ""
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def _get_auth_username() -> str:
|
def _get_auth_username() -> str:
|
||||||
@@ -116,25 +104,43 @@ def _get_auth_username() -> str:
|
|||||||
return param.GetString("AuthUsername", "")
|
return param.GetString("AuthUsername", "")
|
||||||
|
|
||||||
|
|
||||||
def _get_auth_headers() -> Dict[str, str]:
|
def _get_auth_role() -> str:
|
||||||
"""Return Authorization header dict if authenticated, else empty dict.
|
"""Get stored authenticated user role from preferences."""
|
||||||
|
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||||
|
return param.GetString("AuthRole", "")
|
||||||
|
|
||||||
Checks login-based token first, then falls back to static API token.
|
|
||||||
"""
|
def _get_auth_source() -> str:
|
||||||
|
"""Get stored authentication source from preferences."""
|
||||||
|
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||||
|
return param.GetString("AuthSource", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_auth_headers() -> Dict[str, str]:
|
||||||
|
"""Return Authorization header dict if a token is configured, else empty."""
|
||||||
token = _get_auth_token()
|
token = _get_auth_token()
|
||||||
if not token:
|
|
||||||
token = _get_api_token()
|
|
||||||
if token:
|
if token:
|
||||||
return {"Authorization": f"Bearer {token}"}
|
return {"Authorization": f"Bearer {token}"}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_auth_info(username: str, role: str = "", source: str = "", token: str = ""):
|
||||||
|
"""Store authentication info in preferences."""
|
||||||
|
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||||
|
param.SetString("AuthUsername", username)
|
||||||
|
param.SetString("AuthRole", role)
|
||||||
|
param.SetString("AuthSource", source)
|
||||||
|
if token:
|
||||||
|
param.SetString("ApiToken", token)
|
||||||
|
|
||||||
|
|
||||||
def _clear_auth():
|
def _clear_auth():
|
||||||
"""Clear stored authentication credentials from preferences."""
|
"""Clear stored authentication credentials from preferences."""
|
||||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||||
param.SetString("AuthToken", "")
|
param.SetString("ApiToken", "")
|
||||||
param.SetString("AuthUsername", "")
|
param.SetString("AuthUsername", "")
|
||||||
param.SetString("AuthTokenExpiry", "")
|
param.SetString("AuthRole", "")
|
||||||
|
param.SetString("AuthSource", "")
|
||||||
|
|
||||||
|
|
||||||
# Category name mapping for folder structure
|
# Category name mapping for folder structure
|
||||||
@@ -636,50 +642,190 @@ class SiloClient:
|
|||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
raise RuntimeError(f"Connection error: {e.reason}")
|
raise RuntimeError(f"Connection error: {e.reason}")
|
||||||
|
|
||||||
def login(self, username: str, password: str) -> Dict[str, Any]:
|
# -- Authentication methods ---------------------------------------------
|
||||||
"""Authenticate with the Silo API and store the token."""
|
|
||||||
url = f"{self.base_url}/auth/login"
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
body = json.dumps({"username": username, "password": password}).encode()
|
|
||||||
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
||||||
|
|
||||||
|
def login(self, username: str, password: str) -> Dict[str, Any]:
|
||||||
|
"""Authenticate with credentials and obtain an API token.
|
||||||
|
|
||||||
|
Performs a session-based login (POST /login), then uses the
|
||||||
|
session to create a persistent API token via POST /api/auth/tokens.
|
||||||
|
The API token is stored in preferences for future requests.
|
||||||
|
"""
|
||||||
|
import http.cookiejar
|
||||||
|
|
||||||
|
# Build a cookie-aware opener for the session flow
|
||||||
|
base = self.base_url
|
||||||
|
# Strip /api suffix to get the server root for /login
|
||||||
|
origin = base.rsplit("/api", 1)[0] if base.endswith("/api") else base
|
||||||
|
ctx = _get_ssl_context()
|
||||||
|
cookie_jar = http.cookiejar.CookieJar()
|
||||||
|
opener = urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPCookieProcessor(cookie_jar),
|
||||||
|
urllib.request.HTTPSHandler(context=ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 1: POST form-encoded credentials to /login
|
||||||
|
login_url = f"{origin}/login"
|
||||||
|
form_data = urllib.parse.urlencode(
|
||||||
|
{"username": username, "password": password}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
login_url,
|
||||||
|
data=form_data,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
opener.open(req)
|
||||||
result = json.loads(resp.read().decode())
|
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
error_body = e.read().decode()
|
if e.code in (302, 303):
|
||||||
try:
|
pass # Redirect after login is expected
|
||||||
detail = json.loads(error_body).get("error", error_body)
|
else:
|
||||||
except (json.JSONDecodeError, AttributeError):
|
raise RuntimeError(
|
||||||
detail = error_body
|
f"Login failed (HTTP {e.code}): invalid credentials or server error"
|
||||||
raise RuntimeError(f"Login failed: {detail}")
|
)
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
raise RuntimeError(f"Connection error: {e.reason}")
|
raise RuntimeError(f"Connection error: {e.reason}")
|
||||||
|
|
||||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
# Step 2: Verify session by calling /api/auth/me
|
||||||
param.SetString("AuthToken", result.get("token", ""))
|
me_url = f"{origin}/api/auth/me"
|
||||||
param.SetString("AuthUsername", result.get("username", username))
|
me_req = urllib.request.Request(me_url, method="GET")
|
||||||
param.SetString("AuthTokenExpiry", result.get("expires_at", ""))
|
try:
|
||||||
return result
|
with opener.open(me_req) as resp:
|
||||||
|
user_info = json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 401:
|
||||||
|
raise RuntimeError("Login failed: invalid username or password")
|
||||||
|
raise RuntimeError(f"Login verification failed (HTTP {e.code})")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise RuntimeError(f"Connection error: {e.reason}")
|
||||||
|
|
||||||
|
# Step 3: Create a persistent API token for FreeCAD
|
||||||
|
token_url = f"{origin}/api/auth/tokens"
|
||||||
|
import socket
|
||||||
|
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
token_body = json.dumps(
|
||||||
|
{"name": f"FreeCAD ({hostname})", "expires_in_days": 90}
|
||||||
|
).encode()
|
||||||
|
token_req = urllib.request.Request(
|
||||||
|
token_url,
|
||||||
|
data=token_body,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with opener.open(token_req) as resp:
|
||||||
|
token_result = json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise RuntimeError(f"Failed to create API token (HTTP {e.code})")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise RuntimeError(f"Connection error: {e.reason}")
|
||||||
|
|
||||||
|
raw_token = token_result.get("token", "")
|
||||||
|
if not raw_token:
|
||||||
|
raise RuntimeError("Server did not return an API token")
|
||||||
|
|
||||||
|
# Store token and user info
|
||||||
|
_save_auth_info(
|
||||||
|
username=user_info.get("username", username),
|
||||||
|
role=user_info.get("role", ""),
|
||||||
|
source=user_info.get("auth_source", ""),
|
||||||
|
token=raw_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"username": user_info.get("username", username),
|
||||||
|
"role": user_info.get("role", ""),
|
||||||
|
"auth_source": user_info.get("auth_source", ""),
|
||||||
|
"token_name": token_result.get("name", ""),
|
||||||
|
"token_prefix": token_result.get("token_prefix", ""),
|
||||||
|
}
|
||||||
|
|
||||||
def logout(self):
|
def logout(self):
|
||||||
"""Clear stored authentication credentials."""
|
"""Clear stored API token and authentication info."""
|
||||||
_clear_auth()
|
_clear_auth()
|
||||||
|
|
||||||
def is_authenticated(self) -> bool:
|
def is_authenticated(self) -> bool:
|
||||||
"""Return True if a valid auth token is stored."""
|
"""Return True if a valid API token is configured."""
|
||||||
return bool(_get_auth_token())
|
return bool(_get_auth_token())
|
||||||
|
|
||||||
def auth_username(self) -> str:
|
def auth_username(self) -> str:
|
||||||
"""Return the stored authenticated username."""
|
"""Return the stored authenticated username."""
|
||||||
return _get_auth_username()
|
return _get_auth_username()
|
||||||
|
|
||||||
|
def auth_role(self) -> str:
|
||||||
|
"""Return the stored user role."""
|
||||||
|
return _get_auth_role()
|
||||||
|
|
||||||
|
def auth_source(self) -> str:
|
||||||
|
"""Return the stored authentication source (local, ldap, oidc)."""
|
||||||
|
return _get_auth_source()
|
||||||
|
|
||||||
|
def get_current_user(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Fetch the current user info from the server.
|
||||||
|
|
||||||
|
Returns user dict or None if not authenticated.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self._request("GET", "/auth/me")
|
||||||
|
except RuntimeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def refresh_auth_info(self) -> bool:
|
||||||
|
"""Refresh locally cached user info from the server.
|
||||||
|
|
||||||
|
Returns True if authenticated, False otherwise.
|
||||||
|
"""
|
||||||
|
user = self.get_current_user()
|
||||||
|
if user and user.get("username"):
|
||||||
|
_save_auth_info(
|
||||||
|
username=user["username"],
|
||||||
|
role=user.get("role", ""),
|
||||||
|
source=user.get("auth_source", ""),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def list_tokens(self) -> List[Dict[str, Any]]:
|
||||||
|
"""List API tokens for the current user."""
|
||||||
|
return self._request("GET", "/auth/tokens")
|
||||||
|
|
||||||
|
def create_token(
|
||||||
|
self, name: str, expires_in_days: Optional[int] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Create a new API token.
|
||||||
|
|
||||||
|
Returns dict with 'token' (raw, shown once), 'id', 'name', etc.
|
||||||
|
"""
|
||||||
|
data: Dict[str, Any] = {"name": name}
|
||||||
|
if expires_in_days is not None:
|
||||||
|
data["expires_in_days"] = expires_in_days
|
||||||
|
return self._request("POST", "/auth/tokens", data)
|
||||||
|
|
||||||
|
def revoke_token(self, token_id: str) -> None:
|
||||||
|
"""Revoke an API token by its ID."""
|
||||||
|
url = f"{self.base_url}/auth/tokens/{token_id}"
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
headers.update(_get_auth_headers())
|
||||||
|
req = urllib.request.Request(url, headers=headers, method="DELETE")
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, context=_get_ssl_context())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode()
|
||||||
|
raise RuntimeError(f"API error {e.code}: {error_body}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise RuntimeError(f"Connection error: {e.reason}")
|
||||||
|
|
||||||
def check_connection(self) -> Tuple[bool, str]:
|
def check_connection(self) -> Tuple[bool, str]:
|
||||||
"""Check connectivity to the Silo API.
|
"""Check connectivity to the Silo API.
|
||||||
|
|
||||||
Returns (reachable, message).
|
Returns (reachable, message).
|
||||||
"""
|
"""
|
||||||
url = f"{self.base_url}/health"
|
# Use origin /health (not /api/health) since health is at root
|
||||||
|
base = self.base_url
|
||||||
|
origin = base.rsplit("/api", 1)[0] if base.endswith("/api") else base
|
||||||
|
url = f"{origin}/health"
|
||||||
req = urllib.request.Request(url, method="GET")
|
req = urllib.request.Request(url, method="GET")
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(
|
with urllib.request.urlopen(
|
||||||
@@ -687,7 +833,6 @@ class SiloClient:
|
|||||||
) as resp:
|
) as resp:
|
||||||
return True, f"OK ({resp.status})"
|
return True, f"OK ({resp.status})"
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
# Server responded - reachable even if error
|
|
||||||
return True, f"Server error ({e.code})"
|
return True, f"Server error ({e.code})"
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
return False, str(e.reason)
|
return False, str(e.reason)
|
||||||
@@ -2145,31 +2290,74 @@ class Silo_Settings:
|
|||||||
layout.addWidget(auth_heading)
|
layout.addWidget(auth_heading)
|
||||||
|
|
||||||
auth_user = _get_auth_username()
|
auth_user = _get_auth_username()
|
||||||
auth_status_text = (
|
auth_role = _get_auth_role()
|
||||||
f"Logged in as <b>{auth_user}</b>"
|
auth_source = _get_auth_source()
|
||||||
if auth_user and _get_auth_token()
|
has_token = bool(_get_auth_token())
|
||||||
else "Not logged in"
|
|
||||||
)
|
if has_token and auth_user:
|
||||||
|
auth_parts = [f"Logged in as <b>{auth_user}</b>"]
|
||||||
|
if auth_role:
|
||||||
|
auth_parts.append(f"(role: {auth_role})")
|
||||||
|
if auth_source:
|
||||||
|
auth_parts.append(f"via {auth_source}")
|
||||||
|
auth_status_text = " ".join(auth_parts)
|
||||||
|
else:
|
||||||
|
auth_status_text = "Not logged in"
|
||||||
|
|
||||||
auth_status_lbl = QtGui.QLabel(auth_status_text)
|
auth_status_lbl = QtGui.QLabel(auth_status_text)
|
||||||
auth_status_lbl.setTextFormat(QtCore.Qt.RichText)
|
auth_status_lbl.setTextFormat(QtCore.Qt.RichText)
|
||||||
layout.addWidget(auth_status_lbl)
|
layout.addWidget(auth_status_lbl)
|
||||||
|
|
||||||
auth_hint = QtGui.QLabel(
|
# API token input
|
||||||
"Use the Database Auth panel to log in. "
|
token_label = QtGui.QLabel("API Token:")
|
||||||
"Credentials are stored locally in FreeCAD preferences."
|
layout.addWidget(token_label)
|
||||||
)
|
|
||||||
auth_hint.setWordWrap(True)
|
|
||||||
auth_hint.setStyleSheet("color: #888; font-size: 11px;")
|
|
||||||
layout.addWidget(auth_hint)
|
|
||||||
|
|
||||||
clear_auth_btn = QtGui.QPushButton("Clear Saved Credentials")
|
token_row = QtGui.QHBoxLayout()
|
||||||
clear_auth_btn.setEnabled(bool(_get_auth_token()))
|
token_input = QtGui.QLineEdit()
|
||||||
|
token_input.setEchoMode(QtGui.QLineEdit.Password)
|
||||||
|
token_input.setPlaceholderText("silo_... (paste token or use Login)")
|
||||||
|
current_token = param.GetString("ApiToken", "")
|
||||||
|
if current_token:
|
||||||
|
token_input.setText(current_token)
|
||||||
|
token_row.addWidget(token_input)
|
||||||
|
|
||||||
|
token_show_btn = QtGui.QToolButton()
|
||||||
|
token_show_btn.setText("\U0001f441")
|
||||||
|
token_show_btn.setCheckable(True)
|
||||||
|
token_show_btn.setFixedSize(28, 28)
|
||||||
|
token_show_btn.setToolTip("Show/hide token")
|
||||||
|
|
||||||
|
def on_toggle_show(checked):
|
||||||
|
if checked:
|
||||||
|
token_input.setEchoMode(QtGui.QLineEdit.Normal)
|
||||||
|
else:
|
||||||
|
token_input.setEchoMode(QtGui.QLineEdit.Password)
|
||||||
|
|
||||||
|
token_show_btn.toggled.connect(on_toggle_show)
|
||||||
|
token_row.addWidget(token_show_btn)
|
||||||
|
layout.addLayout(token_row)
|
||||||
|
|
||||||
|
token_hint = QtGui.QLabel(
|
||||||
|
"Paste an API token generated from the Silo web UI, "
|
||||||
|
"or use Login in the Database Auth panel to create one "
|
||||||
|
"automatically. Tokens can also be set via the "
|
||||||
|
"SILO_API_TOKEN environment variable."
|
||||||
|
)
|
||||||
|
token_hint.setWordWrap(True)
|
||||||
|
token_hint.setStyleSheet("color: #888; font-size: 11px;")
|
||||||
|
layout.addWidget(token_hint)
|
||||||
|
|
||||||
|
layout.addSpacing(4)
|
||||||
|
|
||||||
|
clear_auth_btn = QtGui.QPushButton("Clear Token and Logout")
|
||||||
|
clear_auth_btn.setEnabled(has_token)
|
||||||
|
|
||||||
def on_clear_auth():
|
def on_clear_auth():
|
||||||
_clear_auth()
|
_clear_auth()
|
||||||
|
token_input.setText("")
|
||||||
auth_status_lbl.setText("Not logged in")
|
auth_status_lbl.setText("Not logged in")
|
||||||
clear_auth_btn.setEnabled(False)
|
clear_auth_btn.setEnabled(False)
|
||||||
FreeCAD.Console.PrintMessage("Silo: Saved credentials cleared\n")
|
FreeCAD.Console.PrintMessage("Silo: API token and credentials cleared\n")
|
||||||
|
|
||||||
clear_auth_btn.clicked.connect(on_clear_auth)
|
clear_auth_btn.clicked.connect(on_clear_auth)
|
||||||
layout.addWidget(clear_auth_btn)
|
layout.addWidget(clear_auth_btn)
|
||||||
@@ -2178,11 +2366,14 @@ class Silo_Settings:
|
|||||||
|
|
||||||
# Current effective values (read-only)
|
# Current effective values (read-only)
|
||||||
cert_display = param.GetString("SslCertPath", "") or "(system defaults)"
|
cert_display = param.GetString("SslCertPath", "") or "(system defaults)"
|
||||||
auth_display = (
|
if has_token and auth_user:
|
||||||
f"logged in as {auth_user}"
|
auth_display = f"{auth_user} ({auth_role or 'unknown role'})"
|
||||||
if auth_user and _get_auth_token()
|
if auth_source:
|
||||||
else "not logged in"
|
auth_display += f" via {auth_source}"
|
||||||
)
|
elif has_token:
|
||||||
|
auth_display = "token configured (user unknown)"
|
||||||
|
else:
|
||||||
|
auth_display = "not configured"
|
||||||
status_label = QtGui.QLabel(
|
status_label = QtGui.QLabel(
|
||||||
f"<b>Active URL:</b> {_get_api_url()}<br>"
|
f"<b>Active URL:</b> {_get_api_url()}<br>"
|
||||||
f"<b>SSL verification:</b> {'enabled' if _get_ssl_verify() else 'disabled'}<br>"
|
f"<b>SSL verification:</b> {'enabled' if _get_ssl_verify() else 'disabled'}<br>"
|
||||||
@@ -2209,6 +2400,18 @@ class Silo_Settings:
|
|||||||
param.SetBool("SslVerify", ssl_checkbox.isChecked())
|
param.SetBool("SslVerify", ssl_checkbox.isChecked())
|
||||||
cert_path = cert_input.text().strip()
|
cert_path = cert_input.text().strip()
|
||||||
param.SetString("SslCertPath", cert_path)
|
param.SetString("SslCertPath", cert_path)
|
||||||
|
# Save API token if changed
|
||||||
|
new_token = token_input.text().strip()
|
||||||
|
old_token = param.GetString("ApiToken", "")
|
||||||
|
if new_token != old_token:
|
||||||
|
param.SetString("ApiToken", new_token)
|
||||||
|
if new_token and not old_token:
|
||||||
|
FreeCAD.Console.PrintMessage("Silo: API token configured\n")
|
||||||
|
elif not new_token and old_token:
|
||||||
|
_clear_auth()
|
||||||
|
FreeCAD.Console.PrintMessage("Silo: API token removed\n")
|
||||||
|
else:
|
||||||
|
FreeCAD.Console.PrintMessage("Silo: API token updated\n")
|
||||||
FreeCAD.Console.PrintMessage(
|
FreeCAD.Console.PrintMessage(
|
||||||
f"Silo settings saved. URL: {_get_api_url()}, "
|
f"Silo settings saved. URL: {_get_api_url()}, "
|
||||||
f"SSL verify: {_get_ssl_verify()}, "
|
f"SSL verify: {_get_ssl_verify()}, "
|
||||||
@@ -2728,6 +2931,18 @@ class SiloAuthDockWidget:
|
|||||||
user_row.addStretch()
|
user_row.addStretch()
|
||||||
layout.addLayout(user_row)
|
layout.addLayout(user_row)
|
||||||
|
|
||||||
|
# Role row
|
||||||
|
role_row = QtGui.QHBoxLayout()
|
||||||
|
role_row.setSpacing(6)
|
||||||
|
role_lbl = QtGui.QLabel("Role:")
|
||||||
|
role_lbl.setStyleSheet("color: #888;")
|
||||||
|
self._role_label = QtGui.QLabel("")
|
||||||
|
self._role_label.setStyleSheet("font-size: 11px;")
|
||||||
|
role_row.addWidget(role_lbl)
|
||||||
|
role_row.addWidget(self._role_label)
|
||||||
|
role_row.addStretch()
|
||||||
|
layout.addLayout(role_row)
|
||||||
|
|
||||||
layout.addSpacing(4)
|
layout.addSpacing(4)
|
||||||
|
|
||||||
# URL row (compact display)
|
# URL row (compact display)
|
||||||
@@ -2771,13 +2986,10 @@ class SiloAuthDockWidget:
|
|||||||
# Update URL display
|
# Update URL display
|
||||||
self._url_label.setText(_get_api_url())
|
self._url_label.setText(_get_api_url())
|
||||||
|
|
||||||
authed = _client.is_authenticated()
|
has_token = _client.is_authenticated()
|
||||||
username = _client.auth_username()
|
username = _client.auth_username()
|
||||||
|
role = _client.auth_role()
|
||||||
if authed and username:
|
source = _client.auth_source()
|
||||||
self._user_label.setText(username)
|
|
||||||
else:
|
|
||||||
self._user_label.setText("(not logged in)")
|
|
||||||
|
|
||||||
# Check server connectivity
|
# Check server connectivity
|
||||||
try:
|
try:
|
||||||
@@ -2785,32 +2997,53 @@ class SiloAuthDockWidget:
|
|||||||
except Exception:
|
except Exception:
|
||||||
reachable = False
|
reachable = False
|
||||||
|
|
||||||
|
# If reachable and we have a token, validate it against the server
|
||||||
|
authed = False
|
||||||
|
if reachable and has_token:
|
||||||
|
user = _client.get_current_user()
|
||||||
|
if user and user.get("username"):
|
||||||
|
authed = True
|
||||||
|
username = user["username"]
|
||||||
|
role = user.get("role", "")
|
||||||
|
source = user.get("auth_source", "")
|
||||||
|
_save_auth_info(username=username, role=role, source=source)
|
||||||
|
|
||||||
|
if authed:
|
||||||
|
self._user_label.setText(username)
|
||||||
|
role_text = role or ""
|
||||||
|
if source:
|
||||||
|
role_text += f" ({source})" if role_text else source
|
||||||
|
self._role_label.setText(role_text)
|
||||||
|
else:
|
||||||
|
self._user_label.setText("(not logged in)")
|
||||||
|
self._role_label.setText("")
|
||||||
|
|
||||||
|
# Update button state
|
||||||
|
try:
|
||||||
|
self._login_btn.clicked.disconnect()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
if reachable and authed:
|
if reachable and authed:
|
||||||
self._status_dot.setStyleSheet("color: #4CAF50; font-size: 10px;")
|
self._status_dot.setStyleSheet("color: #4CAF50; font-size: 10px;")
|
||||||
self._status_label.setText("Connected")
|
self._status_label.setText("Connected")
|
||||||
self._login_btn.setText("Logout")
|
self._login_btn.setText("Logout")
|
||||||
try:
|
|
||||||
self._login_btn.clicked.disconnect()
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
self._login_btn.clicked.connect(self._on_logout_clicked)
|
self._login_btn.clicked.connect(self._on_logout_clicked)
|
||||||
elif reachable and not authed:
|
elif reachable and has_token and not authed:
|
||||||
|
# Token exists but is invalid/expired
|
||||||
|
self._status_dot.setStyleSheet("color: #FF9800; font-size: 10px;")
|
||||||
|
self._status_label.setText("Token invalid")
|
||||||
|
self._login_btn.setText("Login")
|
||||||
|
self._login_btn.clicked.connect(self._on_login_clicked)
|
||||||
|
elif reachable and not has_token:
|
||||||
self._status_dot.setStyleSheet("color: #FFC107; font-size: 10px;")
|
self._status_dot.setStyleSheet("color: #FFC107; font-size: 10px;")
|
||||||
self._status_label.setText("Connected (no auth)")
|
self._status_label.setText("Connected (no auth)")
|
||||||
self._login_btn.setText("Login")
|
self._login_btn.setText("Login")
|
||||||
try:
|
|
||||||
self._login_btn.clicked.disconnect()
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
self._login_btn.clicked.connect(self._on_login_clicked)
|
self._login_btn.clicked.connect(self._on_login_clicked)
|
||||||
else:
|
else:
|
||||||
self._status_dot.setStyleSheet("color: #F44336; font-size: 10px;")
|
self._status_dot.setStyleSheet("color: #F44336; font-size: 10px;")
|
||||||
self._status_label.setText("Disconnected")
|
self._status_label.setText("Disconnected")
|
||||||
self._login_btn.setText("Login")
|
self._login_btn.setText("Login")
|
||||||
try:
|
|
||||||
self._login_btn.clicked.disconnect()
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
self._login_btn.clicked.connect(self._on_login_clicked)
|
self._login_btn.clicked.connect(self._on_login_clicked)
|
||||||
|
|
||||||
# -- Actions ------------------------------------------------------------
|
# -- Actions ------------------------------------------------------------
|
||||||
@@ -2833,7 +3066,7 @@ class SiloAuthDockWidget:
|
|||||||
|
|
||||||
dialog = QtGui.QDialog(self.widget)
|
dialog = QtGui.QDialog(self.widget)
|
||||||
dialog.setWindowTitle("Silo Login")
|
dialog.setWindowTitle("Silo Login")
|
||||||
dialog.setMinimumWidth(350)
|
dialog.setMinimumWidth(380)
|
||||||
|
|
||||||
layout = QtGui.QVBoxLayout(dialog)
|
layout = QtGui.QVBoxLayout(dialog)
|
||||||
|
|
||||||
@@ -2842,14 +3075,23 @@ class SiloAuthDockWidget:
|
|||||||
server_label.setStyleSheet("color: #888; font-size: 11px;")
|
server_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||||
layout.addWidget(server_label)
|
layout.addWidget(server_label)
|
||||||
|
|
||||||
|
layout.addSpacing(4)
|
||||||
|
|
||||||
|
info_label = QtGui.QLabel(
|
||||||
|
"Enter your credentials to create a persistent API token. "
|
||||||
|
"Supports local accounts and LDAP (FreeIPA)."
|
||||||
|
)
|
||||||
|
info_label.setWordWrap(True)
|
||||||
|
info_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||||
|
layout.addWidget(info_label)
|
||||||
|
|
||||||
layout.addSpacing(8)
|
layout.addSpacing(8)
|
||||||
|
|
||||||
# Username
|
# Username
|
||||||
user_label = QtGui.QLabel("Username:")
|
user_label = QtGui.QLabel("Username:")
|
||||||
layout.addWidget(user_label)
|
layout.addWidget(user_label)
|
||||||
user_input = QtGui.QLineEdit()
|
user_input = QtGui.QLineEdit()
|
||||||
user_input.setPlaceholderText("LDAP username")
|
user_input.setPlaceholderText("Username")
|
||||||
# Pre-fill with last known username
|
|
||||||
last_user = _get_auth_username()
|
last_user = _get_auth_username()
|
||||||
if last_user:
|
if last_user:
|
||||||
user_input.setText(last_user)
|
user_input.setText(last_user)
|
||||||
@@ -2862,17 +3104,16 @@ class SiloAuthDockWidget:
|
|||||||
layout.addWidget(pass_label)
|
layout.addWidget(pass_label)
|
||||||
pass_input = QtGui.QLineEdit()
|
pass_input = QtGui.QLineEdit()
|
||||||
pass_input.setEchoMode(QtGui.QLineEdit.Password)
|
pass_input.setEchoMode(QtGui.QLineEdit.Password)
|
||||||
pass_input.setPlaceholderText("LDAP password")
|
pass_input.setPlaceholderText("Password")
|
||||||
layout.addWidget(pass_input)
|
layout.addWidget(pass_input)
|
||||||
|
|
||||||
layout.addSpacing(4)
|
layout.addSpacing(4)
|
||||||
|
|
||||||
# Error label (hidden initially)
|
# Error / status label
|
||||||
error_label = QtGui.QLabel("")
|
status_label = QtGui.QLabel("")
|
||||||
error_label.setStyleSheet("color: #F44336;")
|
status_label.setWordWrap(True)
|
||||||
error_label.setWordWrap(True)
|
status_label.setVisible(False)
|
||||||
error_label.setVisible(False)
|
layout.addWidget(status_label)
|
||||||
layout.addWidget(error_label)
|
|
||||||
|
|
||||||
layout.addSpacing(8)
|
layout.addSpacing(8)
|
||||||
|
|
||||||
@@ -2889,16 +3130,35 @@ class SiloAuthDockWidget:
|
|||||||
username = user_input.text().strip()
|
username = user_input.text().strip()
|
||||||
password = pass_input.text()
|
password = pass_input.text()
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
error_label.setText("Username and password are required.")
|
status_label.setText("Username and password are required.")
|
||||||
error_label.setVisible(True)
|
status_label.setStyleSheet("color: #F44336;")
|
||||||
|
status_label.setVisible(True)
|
||||||
return
|
return
|
||||||
|
# Disable inputs during login
|
||||||
|
login_btn.setEnabled(False)
|
||||||
|
status_label.setText("Logging in...")
|
||||||
|
status_label.setStyleSheet("color: #888;")
|
||||||
|
status_label.setVisible(True)
|
||||||
|
# Process events so the user sees the status update
|
||||||
|
from PySide.QtWidgets import QApplication
|
||||||
|
|
||||||
|
QApplication.processEvents()
|
||||||
try:
|
try:
|
||||||
_client.login(username, password)
|
result = _client.login(username, password)
|
||||||
FreeCAD.Console.PrintMessage(f"Silo: Logged in as {username}\n")
|
role = result.get("role", "")
|
||||||
|
source = result.get("auth_source", "")
|
||||||
|
msg = f"Silo: Logged in as {username}"
|
||||||
|
if role:
|
||||||
|
msg += f" ({role})"
|
||||||
|
if source:
|
||||||
|
msg += f" via {source}"
|
||||||
|
FreeCAD.Console.PrintMessage(msg + "\n")
|
||||||
dialog.accept()
|
dialog.accept()
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
error_label.setText(str(e))
|
status_label.setText(str(e))
|
||||||
error_label.setVisible(True)
|
status_label.setStyleSheet("color: #F44336;")
|
||||||
|
status_label.setVisible(True)
|
||||||
|
login_btn.setEnabled(True)
|
||||||
|
|
||||||
login_btn.clicked.connect(on_login)
|
login_btn.clicked.connect(on_login)
|
||||||
cancel_btn.clicked.connect(dialog.reject)
|
cancel_btn.clicked.connect(dialog.reject)
|
||||||
|
|||||||
Reference in New Issue
Block a user