From 75e56402e51120a865e87c752aef736db20a3ed1 Mon Sep 17 00:00:00 2001 From: maffettone Date: Tue, 19 Nov 2024 09:10:22 -0500 Subject: [PATCH 1/4] add: working authentication with sharable singleton to manage client --- bluesky_widgets/examples/tiled_auth.py | 145 +++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 bluesky_widgets/examples/tiled_auth.py diff --git a/bluesky_widgets/examples/tiled_auth.py b/bluesky_widgets/examples/tiled_auth.py new file mode 100644 index 0000000..7d45a09 --- /dev/null +++ b/bluesky_widgets/examples/tiled_auth.py @@ -0,0 +1,145 @@ +import getpass +import sys + +from PyQt5.QtWidgets import ( + QApplication, + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) +from tiled.client import context as tiled_context_module +from tiled.client import from_uri +from tiled.client.context import Context + + +class TiledReaderManager: + """Auth manager that will hold the client and valid API key for the tiled server""" + + client = None + api_key = None + + +class UsernamePasswordMonkeyPatch: + """Monkey patch for getpass and prompt_for_username to allow for non-interactive authentication, + while still using Tiled source code.""" + + def __init__(self, username, password): + self.username = username + self.password = password + + def __call__(self, *args, **kwargs): + return self.password + + def patch_prompt_for_username(self, username=None): + return self.username if username is None else username + + +class TiledAuthWidget(QWidget): + def __init__(self, auth_manager: TiledReaderManager): + super().__init__() + self.setWindowTitle("Tiled Authentication") + + # Layouts + layout = QVBoxLayout() + server_layout = QHBoxLayout() + credentials_layout = QVBoxLayout() + timeout_layout = QHBoxLayout() + button_layout = QHBoxLayout() + + # Server dropdown and manual input + self.server_label = QLabel("Tiled Server:") + self.server_dropdown = QComboBox() + self.server_dropdown.addItems(["https://tiled.nsls2.bnl.gov", "https://example-server2.com"]) + self.server_dropdown.setEditable(True) # Allow manual typing + server_layout.addWidget(self.server_label) + server_layout.addWidget(self.server_dropdown) + + # Username and password fields + self.username_label = QLabel("Username:") + self.username_input = QLineEdit() + self.password_label = QLabel("Password:") + self.password_input = QLineEdit() + self.password_input.setEchoMode(QLineEdit.Password) # Hide password input + credentials_layout.addWidget(self.username_label) + credentials_layout.addWidget(self.username_input) + credentials_layout.addWidget(self.password_label) + credentials_layout.addWidget(self.password_input) + + # API key timeout + self.timeout_label = QLabel("API Key Timeout (hours):") + self.timeout_input = QSpinBox() + self.timeout_input.setRange(1, 168) # Set range for timeout (1 to 168 hours) + self.timeout_input.setValue(12) # Default value + timeout_layout.addWidget(self.timeout_label) + timeout_layout.addWidget(self.timeout_input) + + # Authenticate and generate API key button + self.authenticate_button = QPushButton("Authenticate") + self.authenticate_button.clicked.connect(self.authenticate) + button_layout.addWidget(self.authenticate_button) + + # Assemble layouts + layout.addLayout(server_layout) + layout.addLayout(credentials_layout) + layout.addLayout(timeout_layout) + layout.addLayout(button_layout) + self.setLayout(layout) + + # Actual API key and client + self.auth_manager = auth_manager + + def authenticate(self): + server_url = self.server_dropdown.currentText() + username = self.username_input.text() + password = self.password_input.text() + timeout = self.timeout_input.value() * 3600 # Convert hours to seconds + + if not server_url or not username or not password: + QMessageBox.warning(self, "Error", "All fields must be filled out.") + return + + # Override some of the Tiled Context behavior that depends on TTY input + username_password_monkey_patch = UsernamePasswordMonkeyPatch(username, password) + original_prompt_for_username = tiled_context_module.prompt_for_username + tiled_context_module.prompt_for_username = username_password_monkey_patch.patch_prompt_for_username + original_getpass = getpass.getpass + getpass.getpass = username_password_monkey_patch + + try: + context = Context.from_any_uri(server_url)[0] + context.authenticate(username=username) + QMessageBox.information(self, "Success", "Authentication successful!") + + # Generate API key + key_info = context.create_api_key(scopes=["read:metadata", "read:data"], expires_in=timeout) + QMessageBox.information( + self, + "Success", + f"Authentication successful!\nAPI Key: {key_info['first_eight']}\n" + f"Expires: {key_info['expiration_time'].isoformat()}", + ) + + self.auth_manager.api_key = key_info["secret"] + self.auth_manager.client = from_uri(server_url, api_key=self.auth_manager.api_key) + except Exception as e: + QMessageBox.critical(self, "Authentication Failed", str(e)) + + finally: + # Restore original getpass and prompt_for_username functions + getpass.getpass = original_getpass + tiled_context_module.prompt_for_username = original_prompt_for_username + + +if __name__ == "__main__": + app = QApplication(sys.argv) + auth_manager = TiledReaderManager() + widget = TiledAuthWidget(auth_manager=auth_manager) + widget.show() + # Can use the auth manager as a singleton in visualization widgets + sys.exit(app.exec_()) From 020a3f4fc37a419d5e994343ac5e0a2d2bec2f4e Mon Sep 17 00:00:00 2001 From: maffettone Date: Tue, 19 Nov 2024 09:28:52 -0500 Subject: [PATCH 2/4] add: logout, warning, and key note --- bluesky_widgets/examples/tiled_auth.py | 40 ++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/bluesky_widgets/examples/tiled_auth.py b/bluesky_widgets/examples/tiled_auth.py index 7d45a09..e2c08a8 100644 --- a/bluesky_widgets/examples/tiled_auth.py +++ b/bluesky_widgets/examples/tiled_auth.py @@ -23,6 +23,7 @@ class TiledReaderManager: client = None api_key = None + context = None class UsernamePasswordMonkeyPatch: @@ -47,11 +48,24 @@ def __init__(self, auth_manager: TiledReaderManager): # Layouts layout = QVBoxLayout() + warning_layout = QHBoxLayout() server_layout = QHBoxLayout() credentials_layout = QVBoxLayout() timeout_layout = QHBoxLayout() button_layout = QHBoxLayout() + # Warning label + self.warning_label = QLabel( + "Warning: Logging into a Tiled server allows the users of this app to read " + "all of the data you have access to within the timeout set. Press Logout when finished." + ) + self.warning_label.setWordWrap(True) + self.warning_label.setStyleSheet( + "background-color: #F5F5F5; color: black; font-weight: bold; padding: 5px; " + "border: 2px solid red; border-radius: 5px;" + ) + warning_layout.addWidget(self.warning_label) + # Server dropdown and manual input self.server_label = QLabel("Tiled Server:") self.server_dropdown = QComboBox() @@ -66,6 +80,7 @@ def __init__(self, auth_manager: TiledReaderManager): self.password_label = QLabel("Password:") self.password_input = QLineEdit() self.password_input.setEchoMode(QLineEdit.Password) # Hide password input + self.password_input.returnPressed.connect(self.authenticate) # Allow pressing Enter to authenticate credentials_layout.addWidget(self.username_label) credentials_layout.addWidget(self.username_input) credentials_layout.addWidget(self.password_label) @@ -79,12 +94,19 @@ def __init__(self, auth_manager: TiledReaderManager): timeout_layout.addWidget(self.timeout_label) timeout_layout.addWidget(self.timeout_input) + # Logout button + self.logout_button = QPushButton("Logout") + self.logout_button.clicked.connect(self.logout) + self.logout_button.setEnabled(False) # Initially disabled + button_layout.addWidget(self.logout_button) + # Authenticate and generate API key button self.authenticate_button = QPushButton("Authenticate") self.authenticate_button.clicked.connect(self.authenticate) button_layout.addWidget(self.authenticate_button) # Assemble layouts + layout.addLayout(warning_layout) layout.addLayout(server_layout) layout.addLayout(credentials_layout) layout.addLayout(timeout_layout) @@ -114,10 +136,11 @@ def authenticate(self): try: context = Context.from_any_uri(server_url)[0] context.authenticate(username=username) - QMessageBox.information(self, "Success", "Authentication successful!") # Generate API key - key_info = context.create_api_key(scopes=["read:metadata", "read:data"], expires_in=timeout) + key_info = context.create_api_key( + scopes=["read:metadata", "read:data"], expires_in=timeout, note="Bluesky Widgets Autogenerated Key" + ) QMessageBox.information( self, "Success", @@ -126,7 +149,10 @@ def authenticate(self): ) self.auth_manager.api_key = key_info["secret"] + self.auth_manager.context = context self.auth_manager.client = from_uri(server_url, api_key=self.auth_manager.api_key) + self.logout_button.setEnabled(True) # Enable the logout button + except Exception as e: QMessageBox.critical(self, "Authentication Failed", str(e)) @@ -135,6 +161,16 @@ def authenticate(self): getpass.getpass = original_getpass tiled_context_module.prompt_for_username = original_prompt_for_username + def logout(self): + try: + self.auth_manager.context.revoke_api_key(self.auth_manager.api_key) + self.auth_manager.context.logout() + self.auth_manager.api_key = None + self.auth_manager.client = None + self.logout_button.setEnabled(False) + except Exception as e: + QMessageBox.critical(self, "Logout Failed", str(e)) + if __name__ == "__main__": app = QApplication(sys.argv) From 2ad31c30535dbe243a6339d51be37f618d90a4d0 Mon Sep 17 00:00:00 2001 From: maffettone Date: Tue, 19 Nov 2024 09:33:14 -0500 Subject: [PATCH 3/4] refactor: example run out of construction --- bluesky_widgets/examples/tiled_auth.py | 176 +------------------------ bluesky_widgets/qt/tiled_auth.py | 170 ++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 172 deletions(-) create mode 100644 bluesky_widgets/qt/tiled_auth.py diff --git a/bluesky_widgets/examples/tiled_auth.py b/bluesky_widgets/examples/tiled_auth.py index e2c08a8..1fb5fed 100644 --- a/bluesky_widgets/examples/tiled_auth.py +++ b/bluesky_widgets/examples/tiled_auth.py @@ -1,181 +1,13 @@ -import getpass import sys -from PyQt5.QtWidgets import ( - QApplication, - QComboBox, - QHBoxLayout, - QLabel, - QLineEdit, - QMessageBox, - QPushButton, - QSpinBox, - QVBoxLayout, - QWidget, -) -from tiled.client import context as tiled_context_module -from tiled.client import from_uri -from tiled.client.context import Context - - -class TiledReaderManager: - """Auth manager that will hold the client and valid API key for the tiled server""" - - client = None - api_key = None - context = None - - -class UsernamePasswordMonkeyPatch: - """Monkey patch for getpass and prompt_for_username to allow for non-interactive authentication, - while still using Tiled source code.""" - - def __init__(self, username, password): - self.username = username - self.password = password - - def __call__(self, *args, **kwargs): - return self.password - - def patch_prompt_for_username(self, username=None): - return self.username if username is None else username - - -class TiledAuthWidget(QWidget): - def __init__(self, auth_manager: TiledReaderManager): - super().__init__() - self.setWindowTitle("Tiled Authentication") - - # Layouts - layout = QVBoxLayout() - warning_layout = QHBoxLayout() - server_layout = QHBoxLayout() - credentials_layout = QVBoxLayout() - timeout_layout = QHBoxLayout() - button_layout = QHBoxLayout() - - # Warning label - self.warning_label = QLabel( - "Warning: Logging into a Tiled server allows the users of this app to read " - "all of the data you have access to within the timeout set. Press Logout when finished." - ) - self.warning_label.setWordWrap(True) - self.warning_label.setStyleSheet( - "background-color: #F5F5F5; color: black; font-weight: bold; padding: 5px; " - "border: 2px solid red; border-radius: 5px;" - ) - warning_layout.addWidget(self.warning_label) - - # Server dropdown and manual input - self.server_label = QLabel("Tiled Server:") - self.server_dropdown = QComboBox() - self.server_dropdown.addItems(["https://tiled.nsls2.bnl.gov", "https://example-server2.com"]) - self.server_dropdown.setEditable(True) # Allow manual typing - server_layout.addWidget(self.server_label) - server_layout.addWidget(self.server_dropdown) - - # Username and password fields - self.username_label = QLabel("Username:") - self.username_input = QLineEdit() - self.password_label = QLabel("Password:") - self.password_input = QLineEdit() - self.password_input.setEchoMode(QLineEdit.Password) # Hide password input - self.password_input.returnPressed.connect(self.authenticate) # Allow pressing Enter to authenticate - credentials_layout.addWidget(self.username_label) - credentials_layout.addWidget(self.username_input) - credentials_layout.addWidget(self.password_label) - credentials_layout.addWidget(self.password_input) - - # API key timeout - self.timeout_label = QLabel("API Key Timeout (hours):") - self.timeout_input = QSpinBox() - self.timeout_input.setRange(1, 168) # Set range for timeout (1 to 168 hours) - self.timeout_input.setValue(12) # Default value - timeout_layout.addWidget(self.timeout_label) - timeout_layout.addWidget(self.timeout_input) - - # Logout button - self.logout_button = QPushButton("Logout") - self.logout_button.clicked.connect(self.logout) - self.logout_button.setEnabled(False) # Initially disabled - button_layout.addWidget(self.logout_button) - - # Authenticate and generate API key button - self.authenticate_button = QPushButton("Authenticate") - self.authenticate_button.clicked.connect(self.authenticate) - button_layout.addWidget(self.authenticate_button) - - # Assemble layouts - layout.addLayout(warning_layout) - layout.addLayout(server_layout) - layout.addLayout(credentials_layout) - layout.addLayout(timeout_layout) - layout.addLayout(button_layout) - self.setLayout(layout) - - # Actual API key and client - self.auth_manager = auth_manager - - def authenticate(self): - server_url = self.server_dropdown.currentText() - username = self.username_input.text() - password = self.password_input.text() - timeout = self.timeout_input.value() * 3600 # Convert hours to seconds - - if not server_url or not username or not password: - QMessageBox.warning(self, "Error", "All fields must be filled out.") - return - - # Override some of the Tiled Context behavior that depends on TTY input - username_password_monkey_patch = UsernamePasswordMonkeyPatch(username, password) - original_prompt_for_username = tiled_context_module.prompt_for_username - tiled_context_module.prompt_for_username = username_password_monkey_patch.patch_prompt_for_username - original_getpass = getpass.getpass - getpass.getpass = username_password_monkey_patch - - try: - context = Context.from_any_uri(server_url)[0] - context.authenticate(username=username) - - # Generate API key - key_info = context.create_api_key( - scopes=["read:metadata", "read:data"], expires_in=timeout, note="Bluesky Widgets Autogenerated Key" - ) - QMessageBox.information( - self, - "Success", - f"Authentication successful!\nAPI Key: {key_info['first_eight']}\n" - f"Expires: {key_info['expiration_time'].isoformat()}", - ) - - self.auth_manager.api_key = key_info["secret"] - self.auth_manager.context = context - self.auth_manager.client = from_uri(server_url, api_key=self.auth_manager.api_key) - self.logout_button.setEnabled(True) # Enable the logout button - - except Exception as e: - QMessageBox.critical(self, "Authentication Failed", str(e)) - - finally: - # Restore original getpass and prompt_for_username functions - getpass.getpass = original_getpass - tiled_context_module.prompt_for_username = original_prompt_for_username - - def logout(self): - try: - self.auth_manager.context.revoke_api_key(self.auth_manager.api_key) - self.auth_manager.context.logout() - self.auth_manager.api_key = None - self.auth_manager.client = None - self.logout_button.setEnabled(False) - except Exception as e: - QMessageBox.critical(self, "Logout Failed", str(e)) +from PyQt5.QtWidgets import QApplication +from bluesky_widgets.qt import tiled_auth if __name__ == "__main__": app = QApplication(sys.argv) - auth_manager = TiledReaderManager() - widget = TiledAuthWidget(auth_manager=auth_manager) + auth_manager = tiled_auth.TiledReaderManager() + widget = tiled_auth.TiledAuthWidget(auth_manager=auth_manager) widget.show() # Can use the auth manager as a singleton in visualization widgets sys.exit(app.exec_()) diff --git a/bluesky_widgets/qt/tiled_auth.py b/bluesky_widgets/qt/tiled_auth.py new file mode 100644 index 0000000..9484ae2 --- /dev/null +++ b/bluesky_widgets/qt/tiled_auth.py @@ -0,0 +1,170 @@ +import getpass + +from PyQt5.QtWidgets import ( + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) +from tiled.client import context as tiled_context_module +from tiled.client import from_uri +from tiled.client.context import Context + + +class TiledReaderManager: + """Auth manager that will hold the client and valid API key for the tiled server""" + + client = None + api_key = None + context = None + + +class UsernamePasswordMonkeyPatch: + """Monkey patch for getpass and prompt_for_username to allow for non-interactive authentication, + while still using Tiled source code.""" + + def __init__(self, username, password): + self.username = username + self.password = password + + def __call__(self, *args, **kwargs): + return self.password + + def patch_prompt_for_username(self, username=None): + return self.username if username is None else username + + +class TiledAuthWidget(QWidget): + def __init__(self, auth_manager: TiledReaderManager): + super().__init__() + self.setWindowTitle("Tiled Authentication") + + # Layouts + layout = QVBoxLayout() + warning_layout = QHBoxLayout() + server_layout = QHBoxLayout() + credentials_layout = QVBoxLayout() + timeout_layout = QHBoxLayout() + button_layout = QHBoxLayout() + + # Warning label + self.warning_label = QLabel( + "Warning: Logging into a Tiled server allows the users of this app to read " + "all of the data you have access to within the timeout set. Press Logout when finished." + ) + self.warning_label.setWordWrap(True) + self.warning_label.setStyleSheet( + "background-color: #F5F5F5; color: black; font-weight: bold; padding: 5px; " + "border: 2px solid red; border-radius: 5px;" + ) + warning_layout.addWidget(self.warning_label) + + # Server dropdown and manual input + self.server_label = QLabel("Tiled Server:") + self.server_dropdown = QComboBox() + self.server_dropdown.addItems(["https://tiled.nsls2.bnl.gov", "https://example-server2.com"]) + self.server_dropdown.setEditable(True) # Allow manual typing + server_layout.addWidget(self.server_label) + server_layout.addWidget(self.server_dropdown) + + # Username and password fields + self.username_label = QLabel("Username:") + self.username_input = QLineEdit() + self.password_label = QLabel("Password:") + self.password_input = QLineEdit() + self.password_input.setEchoMode(QLineEdit.Password) # Hide password input + self.password_input.returnPressed.connect(self.authenticate) # Allow pressing Enter to authenticate + credentials_layout.addWidget(self.username_label) + credentials_layout.addWidget(self.username_input) + credentials_layout.addWidget(self.password_label) + credentials_layout.addWidget(self.password_input) + + # API key timeout + self.timeout_label = QLabel("API Key Timeout (hours):") + self.timeout_input = QSpinBox() + self.timeout_input.setRange(1, 168) # Set range for timeout (1 to 168 hours) + self.timeout_input.setValue(12) # Default value + timeout_layout.addWidget(self.timeout_label) + timeout_layout.addWidget(self.timeout_input) + + # Logout button + self.logout_button = QPushButton("Logout") + self.logout_button.clicked.connect(self.logout) + self.logout_button.setEnabled(False) # Initially disabled + button_layout.addWidget(self.logout_button) + + # Authenticate and generate API key button + self.authenticate_button = QPushButton("Authenticate") + self.authenticate_button.clicked.connect(self.authenticate) + button_layout.addWidget(self.authenticate_button) + + # Assemble layouts + layout.addLayout(warning_layout) + layout.addLayout(server_layout) + layout.addLayout(credentials_layout) + layout.addLayout(timeout_layout) + layout.addLayout(button_layout) + self.setLayout(layout) + + # Actual API key and client + self.auth_manager = auth_manager + + def authenticate(self): + server_url = self.server_dropdown.currentText() + username = self.username_input.text() + password = self.password_input.text() + timeout = self.timeout_input.value() * 3600 # Convert hours to seconds + + if not server_url or not username or not password: + QMessageBox.warning(self, "Error", "All fields must be filled out.") + return + + # Override some of the Tiled Context behavior that depends on TTY input + username_password_monkey_patch = UsernamePasswordMonkeyPatch(username, password) + original_prompt_for_username = tiled_context_module.prompt_for_username + tiled_context_module.prompt_for_username = username_password_monkey_patch.patch_prompt_for_username + original_getpass = getpass.getpass + getpass.getpass = username_password_monkey_patch + + try: + context = Context.from_any_uri(server_url)[0] + context.authenticate(username=username) + + # Generate API key + key_info = context.create_api_key( + scopes=["read:metadata", "read:data"], expires_in=timeout, note="Bluesky Widgets Autogenerated Key" + ) + QMessageBox.information( + self, + "Success", + f"Authentication successful!\nAPI Key: {key_info['first_eight']}\n" + f"Expires: {key_info['expiration_time'].isoformat()}", + ) + + self.auth_manager.api_key = key_info["secret"] + self.auth_manager.context = context + self.auth_manager.client = from_uri(server_url, api_key=self.auth_manager.api_key) + self.logout_button.setEnabled(True) # Enable the logout button + + except Exception as e: + QMessageBox.critical(self, "Authentication Failed", str(e)) + + finally: + # Restore original getpass and prompt_for_username functions + getpass.getpass = original_getpass + tiled_context_module.prompt_for_username = original_prompt_for_username + + def logout(self): + try: + self.auth_manager.context.revoke_api_key(self.auth_manager.api_key) + self.auth_manager.context.logout() + self.auth_manager.api_key = None + self.auth_manager.client = None + self.logout_button.setEnabled(False) + except Exception as e: + QMessageBox.critical(self, "Logout Failed", str(e)) From f200a88c63a6a8346b8c6f9460d5b627957d48f8 Mon Sep 17 00:00:00 2001 From: maffettone Date: Tue, 19 Nov 2024 09:54:38 -0500 Subject: [PATCH 4/4] fix: clear name and password after success --- bluesky_widgets/qt/tiled_auth.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bluesky_widgets/qt/tiled_auth.py b/bluesky_widgets/qt/tiled_auth.py index 9484ae2..316eec3 100644 --- a/bluesky_widgets/qt/tiled_auth.py +++ b/bluesky_widgets/qt/tiled_auth.py @@ -149,6 +149,9 @@ def authenticate(self): self.auth_manager.api_key = key_info["secret"] self.auth_manager.context = context self.auth_manager.client = from_uri(server_url, api_key=self.auth_manager.api_key) + + self.username_input.clear() + self.password_input.clear() self.logout_button.setEnabled(True) # Enable the logout button except Exception as e: