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:
Forbes
2026-01-31 16:24:07 -06:00
parent bd03a2c7d1
commit ab15582e26

View File

@@ -91,23 +91,11 @@ def _get_ssl_context() -> ssl.SSLContext:
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
"""Get the active API token for authenticating requests.
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
Priority: ApiToken preference > SILO_API_TOKEN env var.
"""
return _get_api_token()
def _get_auth_username() -> str:
@@ -116,25 +104,43 @@ def _get_auth_username() -> str:
return param.GetString("AuthUsername", "")
def _get_auth_headers() -> Dict[str, str]:
"""Return Authorization header dict if authenticated, else empty dict.
def _get_auth_role() -> str:
"""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()
if not token:
token = _get_api_token()
if token:
return {"Authorization": f"Bearer {token}"}
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():
"""Clear stored authentication credentials from preferences."""
param = FreeCAD.ParamGet(_PREF_GROUP)
param.SetString("AuthToken", "")
param.SetString("ApiToken", "")
param.SetString("AuthUsername", "")
param.SetString("AuthTokenExpiry", "")
param.SetString("AuthRole", "")
param.SetString("AuthSource", "")
# Category name mapping for folder structure
@@ -636,50 +642,190 @@ 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")
# -- Authentication methods ---------------------------------------------
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:
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
result = json.loads(resp.read().decode())
opener.open(req)
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}")
if e.code in (302, 303):
pass # Redirect after login is expected
else:
raise RuntimeError(
f"Login failed (HTTP {e.code}): invalid credentials or server error"
)
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
# Step 2: Verify session by calling /api/auth/me
me_url = f"{origin}/api/auth/me"
me_req = urllib.request.Request(me_url, method="GET")
try:
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):
"""Clear stored authentication credentials."""
"""Clear stored API token and authentication info."""
_clear_auth()
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())
def auth_username(self) -> str:
"""Return the stored authenticated 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]:
"""Check connectivity to the Silo API.
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")
try:
with urllib.request.urlopen(
@@ -687,7 +833,6 @@ class SiloClient:
) 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)
@@ -2145,31 +2290,74 @@ class Silo_Settings:
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_role = _get_auth_role()
auth_source = _get_auth_source()
has_token = bool(_get_auth_token())
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.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)
# API token input
token_label = QtGui.QLabel("API Token:")
layout.addWidget(token_label)
clear_auth_btn = QtGui.QPushButton("Clear Saved Credentials")
clear_auth_btn.setEnabled(bool(_get_auth_token()))
token_row = QtGui.QHBoxLayout()
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():
_clear_auth()
token_input.setText("")
auth_status_lbl.setText("Not logged in")
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)
layout.addWidget(clear_auth_btn)
@@ -2178,11 +2366,14 @@ class Silo_Settings:
# 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"
)
if has_token and auth_user:
auth_display = f"{auth_user} ({auth_role or 'unknown role'})"
if auth_source:
auth_display += f" via {auth_source}"
elif has_token:
auth_display = "token configured (user unknown)"
else:
auth_display = "not configured"
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>"
@@ -2209,6 +2400,18 @@ class Silo_Settings:
param.SetBool("SslVerify", ssl_checkbox.isChecked())
cert_path = cert_input.text().strip()
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(
f"Silo settings saved. URL: {_get_api_url()}, "
f"SSL verify: {_get_ssl_verify()}, "
@@ -2728,6 +2931,18 @@ class SiloAuthDockWidget:
user_row.addStretch()
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)
# URL row (compact display)
@@ -2771,13 +2986,10 @@ class SiloAuthDockWidget:
# Update URL display
self._url_label.setText(_get_api_url())
authed = _client.is_authenticated()
has_token = _client.is_authenticated()
username = _client.auth_username()
if authed and username:
self._user_label.setText(username)
else:
self._user_label.setText("(not logged in)")
role = _client.auth_role()
source = _client.auth_source()
# Check server connectivity
try:
@@ -2785,32 +2997,53 @@ class SiloAuthDockWidget:
except Exception:
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:
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:
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_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 ------------------------------------------------------------
@@ -2833,7 +3066,7 @@ class SiloAuthDockWidget:
dialog = QtGui.QDialog(self.widget)
dialog.setWindowTitle("Silo Login")
dialog.setMinimumWidth(350)
dialog.setMinimumWidth(380)
layout = QtGui.QVBoxLayout(dialog)
@@ -2842,14 +3075,23 @@ class SiloAuthDockWidget:
server_label.setStyleSheet("color: #888; font-size: 11px;")
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)
# 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
user_input.setPlaceholderText("Username")
last_user = _get_auth_username()
if last_user:
user_input.setText(last_user)
@@ -2862,17 +3104,16 @@ class SiloAuthDockWidget:
layout.addWidget(pass_label)
pass_input = QtGui.QLineEdit()
pass_input.setEchoMode(QtGui.QLineEdit.Password)
pass_input.setPlaceholderText("LDAP password")
pass_input.setPlaceholderText("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)
# Error / status label
status_label = QtGui.QLabel("")
status_label.setWordWrap(True)
status_label.setVisible(False)
layout.addWidget(status_label)
layout.addSpacing(8)
@@ -2889,16 +3130,35 @@ class SiloAuthDockWidget:
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)
status_label.setText("Username and password are required.")
status_label.setStyleSheet("color: #F44336;")
status_label.setVisible(True)
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:
_client.login(username, password)
FreeCAD.Console.PrintMessage(f"Silo: Logged in as {username}\n")
result = _client.login(username, password)
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()
except RuntimeError as e:
error_label.setText(str(e))
error_label.setVisible(True)
status_label.setText(str(e))
status_label.setStyleSheet("color: #F44336;")
status_label.setVisible(True)
login_btn.setEnabled(True)
login_btn.clicked.connect(on_login)
cancel_btn.clicked.connect(dialog.reject)