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:
Forbes
2026-01-31 14:35:42 -06:00
parent fc0eb6d2be
commit bd03a2c7d1
3 changed files with 424 additions and 12 deletions

View File

@@ -39,6 +39,7 @@ class SiloWorkbench(FreeCADGui.Workbench):
"Silo_Info",
"Silo_BOM",
"Silo_Settings",
"Silo_Auth",
]
self.appendToolbar("Silo", self.toolbar_commands)

View 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

View File

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