Add Silo authentication dock widget, login flow, and token management
Client-side authentication support for the Silo database API. Adds a compact dock widget for auth status and login, bearer token management on all HTTP requests, and an enhanced settings dialog. Auth helper functions: - _get_auth_token(): reads token from FreeCAD preferences, checks expiry - _get_auth_username(): reads stored username from preferences - _get_auth_headers(): returns Authorization bearer header dict - _clear_auth(): clears token, username, and expiry from preferences SiloClient auth methods: - login(username, password): POSTs to /auth/login, stores token/user/expiry - logout(): clears stored credentials via _clear_auth() - is_authenticated(): checks for valid stored token - auth_username(): returns stored username - check_connection(): GETs /health to test server reachability Auth header injection: - _request(), _download_file(), _upload_file(), delete_bom_entry() all merge _get_auth_headers() into outgoing HTTP requests - _request() clears stored token on 401 response SiloAuthDockWidget: - Status indicator dot (green=authenticated, yellow=no auth, red=disconnected) - Displays current username and server URL - Login/Logout toggle button - Gear button opens Silo_Settings dialog - 30-second QTimer polls connection and auth status - Login dialog with username/password fields and inline error display Silo_Auth command: - Shows/focuses the auth dock panel from menu or toolbar - Registered in workbench toolbar alongside existing commands Silo_Settings enhancements: - New Authentication section showing current auth status - Clear Saved Credentials button - Active values summary now includes authentication state New icon: - silo-auth.svg padlock icon in Catppuccin Mocha style Graceful degradation: when backend auth endpoint does not yet exist, login fails with a clear error and all existing unauthenticated requests continue to work as before (empty auth headers are a no-op).
This commit is contained in:
@@ -39,6 +39,7 @@ class SiloWorkbench(FreeCADGui.Workbench):
|
||||
"Silo_Info",
|
||||
"Silo_BOM",
|
||||
"Silo_Settings",
|
||||
"Silo_Auth",
|
||||
]
|
||||
|
||||
self.appendToolbar("Silo", self.toolbar_commands)
|
||||
|
||||
8
pkg/freecad/resources/icons/silo-auth.svg
Normal file
8
pkg/freecad/resources/icons/silo-auth.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Padlock body -->
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" fill="#313244" stroke="#cba6f7"/>
|
||||
<!-- Padlock shackle -->
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" fill="none" stroke="#89dceb"/>
|
||||
<!-- Keyhole -->
|
||||
<circle cx="12" cy="16" r="1.5" fill="#89dceb" stroke="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 448 B |
@@ -90,6 +90,53 @@ def _get_ssl_context() -> ssl.SSLContext:
|
||||
return ctx
|
||||
|
||||
|
||||
def _get_auth_token() -> str:
|
||||
"""Get stored auth token from preferences, checking expiry."""
|
||||
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"))
|
||||
if datetime.now(timezone.utc) >= exp_dt:
|
||||
_clear_auth()
|
||||
return ""
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return token
|
||||
|
||||
|
||||
def _get_auth_username() -> str:
|
||||
"""Get stored authenticated username from preferences."""
|
||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||
return param.GetString("AuthUsername", "")
|
||||
|
||||
|
||||
def _get_auth_headers() -> Dict[str, str]:
|
||||
"""Return Authorization header dict if authenticated, else empty dict.
|
||||
|
||||
Checks login-based token first, then falls back to static API token.
|
||||
"""
|
||||
token = _get_auth_token()
|
||||
if not token:
|
||||
token = _get_api_token()
|
||||
if token:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
return {}
|
||||
|
||||
|
||||
def _clear_auth():
|
||||
"""Clear stored authentication credentials from preferences."""
|
||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||
param.SetString("AuthToken", "")
|
||||
param.SetString("AuthUsername", "")
|
||||
param.SetString("AuthTokenExpiry", "")
|
||||
|
||||
|
||||
# Category name mapping for folder structure
|
||||
# Format: CCC -> "descriptive_name"
|
||||
CATEGORY_NAMES = {
|
||||
@@ -315,9 +362,7 @@ class SiloClient:
|
||||
"""Make HTTP request to Silo API."""
|
||||
url = f"{self.base_url}{path}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
token = _get_api_token()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
headers.update(_get_auth_headers())
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
|
||||
@@ -325,6 +370,8 @@ class SiloClient:
|
||||
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
_clear_auth()
|
||||
error_body = e.read().decode()
|
||||
raise RuntimeError(f"API error {e.code}: {error_body}")
|
||||
except urllib.error.URLError as e:
|
||||
@@ -333,10 +380,7 @@ class SiloClient:
|
||||
def _download_file(self, part_number: str, revision: int, dest_path: str) -> bool:
|
||||
"""Download a file from MinIO storage."""
|
||||
url = f"{self.base_url}/items/{part_number}/file/{revision}"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
token = _get_api_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
req = urllib.request.Request(url, headers=_get_auth_headers(), method="GET")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
||||
@@ -398,9 +442,7 @@ class SiloClient:
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Content-Length": str(len(body)),
|
||||
}
|
||||
token = _get_api_token()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
headers.update(_get_auth_headers())
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||
|
||||
try:
|
||||
@@ -579,9 +621,11 @@ class SiloClient:
|
||||
def delete_bom_entry(self, parent_pn: str, child_pn: str) -> None:
|
||||
"""Remove a child from a parent's BOM."""
|
||||
url = f"{self.base_url}/items/{parent_pn}/bom/{child_pn}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
headers.update(_get_auth_headers())
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"Content-Type": "application/json"},
|
||||
headers=headers,
|
||||
method="DELETE",
|
||||
)
|
||||
try:
|
||||
@@ -592,6 +636,64 @@ class SiloClient:
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"Connection error: {e.reason}")
|
||||
|
||||
def login(self, username: str, password: str) -> Dict[str, Any]:
|
||||
"""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")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
error_body = e.read().decode()
|
||||
try:
|
||||
detail = json.loads(error_body).get("error", error_body)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
detail = error_body
|
||||
raise RuntimeError(f"Login failed: {detail}")
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"Connection error: {e.reason}")
|
||||
|
||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||
param.SetString("AuthToken", result.get("token", ""))
|
||||
param.SetString("AuthUsername", result.get("username", username))
|
||||
param.SetString("AuthTokenExpiry", result.get("expires_at", ""))
|
||||
return result
|
||||
|
||||
def logout(self):
|
||||
"""Clear stored authentication credentials."""
|
||||
_clear_auth()
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Return True if a valid auth token is stored."""
|
||||
return bool(_get_auth_token())
|
||||
|
||||
def auth_username(self) -> str:
|
||||
"""Return the stored authenticated username."""
|
||||
return _get_auth_username()
|
||||
|
||||
def check_connection(self) -> Tuple[bool, str]:
|
||||
"""Check connectivity to the Silo API.
|
||||
|
||||
Returns (reachable, message).
|
||||
"""
|
||||
url = f"{self.base_url}/health"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
req, context=_get_ssl_context(), timeout=5
|
||||
) as resp:
|
||||
return True, f"OK ({resp.status})"
|
||||
except urllib.error.HTTPError as e:
|
||||
# Server responded - reachable even if error
|
||||
return True, f"Server error ({e.code})"
|
||||
except urllib.error.URLError as e:
|
||||
return False, str(e.reason)
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
_client = SiloClient()
|
||||
|
||||
@@ -2037,12 +2139,55 @@ class Silo_Settings:
|
||||
|
||||
layout.addSpacing(10)
|
||||
|
||||
# Authentication section
|
||||
auth_heading = QtGui.QLabel("<b>Authentication</b>")
|
||||
auth_heading.setTextFormat(QtCore.Qt.RichText)
|
||||
layout.addWidget(auth_heading)
|
||||
|
||||
auth_user = _get_auth_username()
|
||||
auth_status_text = (
|
||||
f"Logged in as <b>{auth_user}</b>"
|
||||
if auth_user and _get_auth_token()
|
||||
else "Not logged in"
|
||||
)
|
||||
auth_status_lbl = QtGui.QLabel(auth_status_text)
|
||||
auth_status_lbl.setTextFormat(QtCore.Qt.RichText)
|
||||
layout.addWidget(auth_status_lbl)
|
||||
|
||||
auth_hint = QtGui.QLabel(
|
||||
"Use the Database Auth panel to log in. "
|
||||
"Credentials are stored locally in FreeCAD preferences."
|
||||
)
|
||||
auth_hint.setWordWrap(True)
|
||||
auth_hint.setStyleSheet("color: #888; font-size: 11px;")
|
||||
layout.addWidget(auth_hint)
|
||||
|
||||
clear_auth_btn = QtGui.QPushButton("Clear Saved Credentials")
|
||||
clear_auth_btn.setEnabled(bool(_get_auth_token()))
|
||||
|
||||
def on_clear_auth():
|
||||
_clear_auth()
|
||||
auth_status_lbl.setText("Not logged in")
|
||||
clear_auth_btn.setEnabled(False)
|
||||
FreeCAD.Console.PrintMessage("Silo: Saved credentials cleared\n")
|
||||
|
||||
clear_auth_btn.clicked.connect(on_clear_auth)
|
||||
layout.addWidget(clear_auth_btn)
|
||||
|
||||
layout.addSpacing(10)
|
||||
|
||||
# Current effective values (read-only)
|
||||
cert_display = param.GetString("SslCertPath", "") or "(system defaults)"
|
||||
auth_display = (
|
||||
f"logged in as {auth_user}"
|
||||
if auth_user and _get_auth_token()
|
||||
else "not logged in"
|
||||
)
|
||||
status_label = QtGui.QLabel(
|
||||
f"<b>Active URL:</b> {_get_api_url()}<br>"
|
||||
f"<b>SSL verification:</b> {'enabled' if _get_ssl_verify() else 'disabled'}<br>"
|
||||
f"<b>CA certificate:</b> {cert_display}"
|
||||
f"<b>CA certificate:</b> {cert_display}<br>"
|
||||
f"<b>Authentication:</b> {auth_display}"
|
||||
)
|
||||
status_label.setTextFormat(QtCore.Qt.RichText)
|
||||
layout.addWidget(status_label)
|
||||
@@ -2532,6 +2677,263 @@ class Silo_ToggleMode:
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth dock widget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SiloAuthDockWidget:
|
||||
"""Content widget for the Silo Database Auth dock panel."""
|
||||
|
||||
def __init__(self):
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
self.widget = QtGui.QWidget()
|
||||
self._build_ui()
|
||||
self._refresh_status()
|
||||
|
||||
self._timer = QtCore.QTimer(self.widget)
|
||||
self._timer.timeout.connect(self._refresh_status)
|
||||
self._timer.start(30000)
|
||||
|
||||
# -- UI construction ----------------------------------------------------
|
||||
|
||||
def _build_ui(self):
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
layout = QtGui.QVBoxLayout(self.widget)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
layout.setSpacing(6)
|
||||
|
||||
# Status row
|
||||
status_row = QtGui.QHBoxLayout()
|
||||
status_row.setSpacing(6)
|
||||
self._status_dot = QtGui.QLabel("\u2b24")
|
||||
self._status_dot.setFixedWidth(16)
|
||||
self._status_dot.setAlignment(QtCore.Qt.AlignCenter)
|
||||
self._status_label = QtGui.QLabel("Checking...")
|
||||
status_row.addWidget(self._status_dot)
|
||||
status_row.addWidget(self._status_label)
|
||||
status_row.addStretch()
|
||||
layout.addLayout(status_row)
|
||||
|
||||
# User row
|
||||
user_row = QtGui.QHBoxLayout()
|
||||
user_row.setSpacing(6)
|
||||
user_lbl = QtGui.QLabel("User:")
|
||||
user_lbl.setStyleSheet("color: #888;")
|
||||
self._user_label = QtGui.QLabel("(not logged in)")
|
||||
user_row.addWidget(user_lbl)
|
||||
user_row.addWidget(self._user_label)
|
||||
user_row.addStretch()
|
||||
layout.addLayout(user_row)
|
||||
|
||||
layout.addSpacing(4)
|
||||
|
||||
# URL row (compact display)
|
||||
url_row = QtGui.QHBoxLayout()
|
||||
url_row.setSpacing(6)
|
||||
url_lbl = QtGui.QLabel("URL:")
|
||||
url_lbl.setStyleSheet("color: #888;")
|
||||
self._url_label = QtGui.QLabel("")
|
||||
self._url_label.setStyleSheet("font-size: 11px;")
|
||||
self._url_label.setWordWrap(True)
|
||||
url_row.addWidget(url_lbl)
|
||||
url_row.addWidget(self._url_label, 1)
|
||||
layout.addLayout(url_row)
|
||||
|
||||
layout.addSpacing(4)
|
||||
|
||||
# Buttons
|
||||
btn_row = QtGui.QHBoxLayout()
|
||||
btn_row.setSpacing(6)
|
||||
|
||||
self._login_btn = QtGui.QPushButton("Login")
|
||||
self._login_btn.clicked.connect(self._on_login_clicked)
|
||||
btn_row.addWidget(self._login_btn)
|
||||
|
||||
settings_btn = QtGui.QToolButton()
|
||||
settings_btn.setText("\u2699")
|
||||
settings_btn.setToolTip("Silo Settings")
|
||||
settings_btn.setFixedSize(28, 28)
|
||||
settings_btn.clicked.connect(self._on_settings_clicked)
|
||||
btn_row.addStretch()
|
||||
btn_row.addWidget(settings_btn)
|
||||
|
||||
layout.addLayout(btn_row)
|
||||
layout.addStretch()
|
||||
|
||||
# -- Status refresh -----------------------------------------------------
|
||||
|
||||
def _refresh_status(self):
|
||||
from PySide import QtGui
|
||||
|
||||
# Update URL display
|
||||
self._url_label.setText(_get_api_url())
|
||||
|
||||
authed = _client.is_authenticated()
|
||||
username = _client.auth_username()
|
||||
|
||||
if authed and username:
|
||||
self._user_label.setText(username)
|
||||
else:
|
||||
self._user_label.setText("(not logged in)")
|
||||
|
||||
# Check server connectivity
|
||||
try:
|
||||
reachable, msg = _client.check_connection()
|
||||
except Exception:
|
||||
reachable = False
|
||||
|
||||
if reachable and authed:
|
||||
self._status_dot.setStyleSheet("color: #4CAF50; font-size: 10px;")
|
||||
self._status_label.setText("Connected")
|
||||
self._login_btn.setText("Logout")
|
||||
try:
|
||||
self._login_btn.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self._login_btn.clicked.connect(self._on_logout_clicked)
|
||||
elif reachable and not authed:
|
||||
self._status_dot.setStyleSheet("color: #FFC107; font-size: 10px;")
|
||||
self._status_label.setText("Connected (no auth)")
|
||||
self._login_btn.setText("Login")
|
||||
try:
|
||||
self._login_btn.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self._login_btn.clicked.connect(self._on_login_clicked)
|
||||
else:
|
||||
self._status_dot.setStyleSheet("color: #F44336; font-size: 10px;")
|
||||
self._status_label.setText("Disconnected")
|
||||
self._login_btn.setText("Login")
|
||||
try:
|
||||
self._login_btn.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self._login_btn.clicked.connect(self._on_login_clicked)
|
||||
|
||||
# -- Actions ------------------------------------------------------------
|
||||
|
||||
def _on_login_clicked(self):
|
||||
self._show_login_dialog()
|
||||
|
||||
def _on_logout_clicked(self):
|
||||
_client.logout()
|
||||
FreeCAD.Console.PrintMessage("Silo: Logged out\n")
|
||||
self._refresh_status()
|
||||
|
||||
def _on_settings_clicked(self):
|
||||
FreeCADGui.runCommand("Silo_Settings")
|
||||
# Refresh after settings may have changed
|
||||
self._refresh_status()
|
||||
|
||||
def _show_login_dialog(self):
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
dialog = QtGui.QDialog(self.widget)
|
||||
dialog.setWindowTitle("Silo Login")
|
||||
dialog.setMinimumWidth(350)
|
||||
|
||||
layout = QtGui.QVBoxLayout(dialog)
|
||||
|
||||
# Server info
|
||||
server_label = QtGui.QLabel(f"Server: {_get_api_url()}")
|
||||
server_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
layout.addWidget(server_label)
|
||||
|
||||
layout.addSpacing(8)
|
||||
|
||||
# Username
|
||||
user_label = QtGui.QLabel("Username:")
|
||||
layout.addWidget(user_label)
|
||||
user_input = QtGui.QLineEdit()
|
||||
user_input.setPlaceholderText("LDAP username")
|
||||
# Pre-fill with last known username
|
||||
last_user = _get_auth_username()
|
||||
if last_user:
|
||||
user_input.setText(last_user)
|
||||
layout.addWidget(user_input)
|
||||
|
||||
layout.addSpacing(4)
|
||||
|
||||
# Password
|
||||
pass_label = QtGui.QLabel("Password:")
|
||||
layout.addWidget(pass_label)
|
||||
pass_input = QtGui.QLineEdit()
|
||||
pass_input.setEchoMode(QtGui.QLineEdit.Password)
|
||||
pass_input.setPlaceholderText("LDAP password")
|
||||
layout.addWidget(pass_input)
|
||||
|
||||
layout.addSpacing(4)
|
||||
|
||||
# Error label (hidden initially)
|
||||
error_label = QtGui.QLabel("")
|
||||
error_label.setStyleSheet("color: #F44336;")
|
||||
error_label.setWordWrap(True)
|
||||
error_label.setVisible(False)
|
||||
layout.addWidget(error_label)
|
||||
|
||||
layout.addSpacing(8)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QtGui.QHBoxLayout()
|
||||
login_btn = QtGui.QPushButton("Login")
|
||||
cancel_btn = QtGui.QPushButton("Cancel")
|
||||
btn_layout.addStretch()
|
||||
btn_layout.addWidget(login_btn)
|
||||
btn_layout.addWidget(cancel_btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def on_login():
|
||||
username = user_input.text().strip()
|
||||
password = pass_input.text()
|
||||
if not username or not password:
|
||||
error_label.setText("Username and password are required.")
|
||||
error_label.setVisible(True)
|
||||
return
|
||||
try:
|
||||
_client.login(username, password)
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Logged in as {username}\n")
|
||||
dialog.accept()
|
||||
except RuntimeError as e:
|
||||
error_label.setText(str(e))
|
||||
error_label.setVisible(True)
|
||||
|
||||
login_btn.clicked.connect(on_login)
|
||||
cancel_btn.clicked.connect(dialog.reject)
|
||||
pass_input.returnPressed.connect(on_login)
|
||||
user_input.returnPressed.connect(lambda: pass_input.setFocus())
|
||||
|
||||
dialog.exec_()
|
||||
self._refresh_status()
|
||||
|
||||
|
||||
class Silo_Auth:
|
||||
"""Show the Silo authentication panel."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "Authentication",
|
||||
"ToolTip": "Show Silo authentication status and login",
|
||||
"Pixmap": _icon("auth"),
|
||||
}
|
||||
|
||||
def Activated(self):
|
||||
from PySide import QtGui
|
||||
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is None:
|
||||
return
|
||||
panel = mw.findChild(QtGui.QDockWidget, "SiloDatabaseAuth")
|
||||
if panel:
|
||||
panel.show()
|
||||
panel.raise_()
|
||||
|
||||
def IsActive(self):
|
||||
return True
|
||||
|
||||
|
||||
# Register commands
|
||||
FreeCADGui.addCommand("Silo_Open", Silo_Open())
|
||||
FreeCADGui.addCommand("Silo_New", Silo_New())
|
||||
@@ -2546,3 +2948,4 @@ FreeCADGui.addCommand("Silo_Rollback", Silo_Rollback())
|
||||
FreeCADGui.addCommand("Silo_SetStatus", Silo_SetStatus())
|
||||
FreeCADGui.addCommand("Silo_Settings", Silo_Settings())
|
||||
FreeCADGui.addCommand("Silo_ToggleMode", Silo_ToggleMode())
|
||||
FreeCADGui.addCommand("Silo_Auth", Silo_Auth())
|
||||
|
||||
Reference in New Issue
Block a user