From bd03a2c7d11b780f785e62b332d87ca0f5a24f91 Mon Sep 17 00:00:00 2001 From: Forbes Date: Sat, 31 Jan 2026 14:35:42 -0600 Subject: [PATCH] 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). --- pkg/freecad/InitGui.py | 1 + pkg/freecad/resources/icons/silo-auth.svg | 8 + pkg/freecad/silo_commands.py | 427 +++++++++++++++++++++- 3 files changed, 424 insertions(+), 12 deletions(-) create mode 100644 pkg/freecad/resources/icons/silo-auth.svg diff --git a/pkg/freecad/InitGui.py b/pkg/freecad/InitGui.py index 3a95a64..e63a98b 100644 --- a/pkg/freecad/InitGui.py +++ b/pkg/freecad/InitGui.py @@ -39,6 +39,7 @@ class SiloWorkbench(FreeCADGui.Workbench): "Silo_Info", "Silo_BOM", "Silo_Settings", + "Silo_Auth", ] self.appendToolbar("Silo", self.toolbar_commands) diff --git a/pkg/freecad/resources/icons/silo-auth.svg b/pkg/freecad/resources/icons/silo-auth.svg new file mode 100644 index 0000000..d05c992 --- /dev/null +++ b/pkg/freecad/resources/icons/silo-auth.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/pkg/freecad/silo_commands.py b/pkg/freecad/silo_commands.py index b63de3b..1d1c7dd 100644 --- a/pkg/freecad/silo_commands.py +++ b/pkg/freecad/silo_commands.py @@ -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("Authentication") + auth_heading.setTextFormat(QtCore.Qt.RichText) + layout.addWidget(auth_heading) + + auth_user = _get_auth_username() + auth_status_text = ( + f"Logged in as {auth_user}" + 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"Active URL: {_get_api_url()}
" f"SSL verification: {'enabled' if _get_ssl_verify() else 'disabled'}
" - f"CA certificate: {cert_display}" + f"CA certificate: {cert_display}
" + f"Authentication: {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())