Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ public class AuthenticationRoute extends BaseRoute {
public Response authenticateUser(@FormParam("username") String username,
@FormParam("password") String password) throws AuthenticationError {
LOGGER.debug("Authenticating user: " + username);
UserInfo userInfo = userFacade.findByUsername(username);
boolean authenticated = LoginController.validateCredentials(userInfo, password);
UserInfo userInfo = userFacade.findByUsername(username);
boolean authenticated = userInfo != null && LoginController.validateCredentials(userInfo, password);

if (authenticated) {
UserSessionKeeper usk = UserSessionKeeper.getInstance();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright (c) UChicago Argonne, LLC. All rights reserved.
* See LICENSE file.
*/
package gov.anl.aps.logr.rest.routes;

import gov.anl.aps.logr.common.exceptions.AuthenticationError;
import gov.anl.aps.logr.portal.model.db.beans.UserInfoFacade;
import gov.anl.aps.logr.portal.model.db.entities.UserInfo;
import java.lang.reflect.Field;

/** Regression test for REST login by an unknown user. */
public class AuthenticationRouteTest {

public static void main(String[] args) throws Exception {
AuthenticationRoute route = new AuthenticationRoute();
Field userFacade = AuthenticationRoute.class.getDeclaredField("userFacade");
userFacade.setAccessible(true);
userFacade.set(route, new UnknownUserFacade());

try {
route.authenticateUser("unknown-user", "password");
throw new AssertionError("Expected authentication to fail");
} catch (AuthenticationError ex) {
if (!"Could not verify username or password.".equals(ex.getMessage())) {
throw new AssertionError("Unexpected authentication error: " + ex.getMessage());
}
}

System.out.println("PASS unknown user returns authentication error");
}

private static class UnknownUserFacade extends UserInfoFacade {
@Override
public UserInfo findByUsername(String username) {
return null;
}
}
}
13 changes: 11 additions & 2 deletions tools/developer_tools/bely-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ lookups (listing types, systems, templates, finding documents) do not.

On success a token is cached at `~/.config/bely/token` (permissions `0600`) and reused on
later runs. Expired or invalid tokens are discarded and you re-authenticate automatically.
To invalidate the current token on the server and remove it locally, run:

```bash
bely-cli logout
```

If no token is cached, the command reports `Not logged in.` and succeeds.

The token location can be changed with the `token_path` setting; by default it sits beside
the settings file (see [Configuration & environment](#configuration--environment)).
Expand Down Expand Up @@ -183,7 +190,7 @@ equivalent of Home's old menu items, plus what Textual provides by default:
|---------|--------|
| Configuration | Opens the configuration dialog — equivalent of `config show` / `config set` / `config edit`. |
| My documents | Opens a browse starting at your recently modified documents — equivalent of `doc list`. `Esc` pops back to wherever you opened it from. |
| Log in | Authenticate now instead of waiting for the first mutation. |
| Log in / Log out | Authenticate now instead of waiting for the first mutation, or end the current authenticated session and remove its cached token. |
| Refresh cache | Discard all cached logbook data so the next view re-fetches from the server. |
| Theme | Built-in: change the app's color theme; the choice is saved as the `theme` setting and reused on the next launch. |
| Quit | Built-in: exit the app. |
Expand Down Expand Up @@ -245,7 +252,9 @@ save a config change, the app looks for the token the CLI already caches (see
already run an authenticated `bely-cli` command, or a previous `tui` session, you won't be
prompted again. Otherwise a login modal appears (username, password, and `Log in`/`Cancel`
buttons — `ctrl+s` also submits, `Esc` also cancels); a successful login is cached the same
way the CLI caches it, shared by later `bely-cli` commands and TUI sessions alike.
way the CLI caches it, shared by later `bely-cli` commands and TUI sessions alike. Once
logged in, the command palette offers `Log out`, which invalidates the server session,
removes the cached token, and returns the TUI to its unauthenticated state.

#### `bely-cli tui lookup`

Expand Down
1 change: 1 addition & 0 deletions tools/developer_tools/bely-cli/run_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ $RUNNER python -m unittest
# (appended to a leaf command, not at the top level).
$RUNNER bely-cli -h > /dev/null
$RUNNER bely-cli doc list -h | grep -q -- --format
$RUNNER bely-cli logout -h | grep -q -- --format
$RUNNER bely-cli tui lookup -h | grep -q -- --format
# Bare `tui` is the one group that carries --limit/--format itself (see CLAUDE.md).
$RUNNER bely-cli tui -h | grep -q -- --limit
25 changes: 25 additions & 0 deletions tools/developer_tools/bely-cli/src/bely_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,31 @@ def authenticated_factory_from_token():
return factory


def logout(factory=None):
"""Invalidate and remove the cached token. Return False if no session exists."""
import belyApi
from BelyApiFactory import BelyApiFactory

token = load_token()
if factory is None:
if not token:
return False
factory = BelyApiFactory(bely_url=get_host())
factory.api_client.set_default_header(BelyApiFactory.HEADER_TOKEN_KEY, token)
try:
factory.logout_user()
except belyApi.exceptions.UnauthorizedException:
pass
except Exception as e:
from .common import format_error_message

raise RuntimeError(f"Logout failed: {format_error_message(e, factory)}") from e
finally:
delete_token()

return True


def login(username, password):
"""Authenticate with credentials, cache the resulting token, and return the factory.

Expand Down
8 changes: 8 additions & 0 deletions tools/developer_tools/bely-cli/src/bely_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .commands import (
cmd_new_doc,
cmd_list_docs,
cmd_logout,
cmd_show_config,
cmd_edit_config,
cmd_set_config,
Expand Down Expand Up @@ -56,6 +57,13 @@ def cli():
pass


@cli.command("logout")
@common_options
def logout(output_format):
"""Log out and remove the cached authentication token."""
cmd_logout(fmt=output_format)


# -- doc --

@cli.group("doc")
Expand Down
7 changes: 7 additions & 0 deletions tools/developer_tools/bely-cli/src/bely_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@
ENV_VARS = core.ENV_VARS


def cmd_logout(fmt="text"):
"""Invalidate the current token and remove it from the local cache."""
logged_out = auth.logout()
message = "Logged out." if logged_out else "Not logged in."
print_result({"logged_out": logged_out}, message, fmt)


def cmd_show_config(fmt="text"):
"""Show current configuration from settings file and environment."""
data = core.collect_config()
Expand Down
22 changes: 20 additions & 2 deletions tools/developer_tools/bely-cli/src/bely_cli/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,12 @@ def get_system_commands(self, screen):
"My documents", "Browse your recently modified documents", self._cmd_recent)
yield SystemCommand(
"Refresh cache", "Discard all cached logbook data", self._cmd_refresh)
yield SystemCommand(
"Log in", "Authenticate now instead of at the first mutation", self._cmd_login)
if self.session.is_authenticated():
yield SystemCommand(
"Log out", "End the current authenticated session", self._cmd_logout)
else:
yield SystemCommand(
"Log in", "Authenticate now instead of at the first mutation", self._cmd_login)

def _cmd_config(self):
from .screens.configscreen import ConfigScreen
Expand All @@ -159,6 +163,20 @@ def _cmd_refresh(self):
def _cmd_login(self):
self.run_worker(self._do_login(), exclusive=True, group="login")

def _cmd_logout(self):
self.run_worker(self._do_logout(), exclusive=True, group="login")

async def _do_logout(self):
try:
await asyncio.to_thread(self.session.logout)
except RuntimeError as e:
self.notify(str(e), severity="error")
screen = self.screen
if isinstance(screen, BrowseScreen):
screen._update_auth_status()
if not self.session.is_authenticated():
self.notify("Logged out.")

async def _do_login(self):
api = await self.ensure_auth()
screen = self.screen
Expand Down
8 changes: 8 additions & 0 deletions tools/developer_tools/bely-cli/src/bely_cli/tui/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ def login(self, username, password):
"""
self._auth_factory = auth.login(username, password)

def logout(self):
"""Invalidate the active session and remove its cached token."""
factory = self._auth_factory
try:
return auth.logout(factory)
finally:
self._auth_factory = None

def authenticated_factory(self):
"""The authenticated BelyApiFactory. Raises RuntimeError if not authenticated yet."""
if self._auth_factory is None:
Expand Down
40 changes: 40 additions & 0 deletions tools/developer_tools/bely-cli/test/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ def authenticate_user(self, username, password):
def get_authenticate_token(self):
return self.api_client.default_headers[self.HEADER_TOKEN_KEY]

def logout_user(self):
if self.api_client.default_headers.get(self.HEADER_TOKEN_KEY) != valid_token:
raise _Unauthorized()

return FakeFactory


Expand Down Expand Up @@ -119,6 +123,42 @@ def test_rejected_token_is_deleted_and_returns_none(self):
self.assertIsNone(auth.load_token())


class LogoutTests(AuthTestCase):
def test_no_cached_token_returns_false(self):
self._install_factory(valid_token="good-token")

self.assertFalse(auth.logout())

def test_valid_cached_token_is_invalidated_and_deleted(self):
self._install_factory(valid_token="good-token")
auth.save_token("good-token")

self.assertTrue(auth.logout())
self.assertIsNone(auth.load_token())

def test_rejected_cached_token_is_still_deleted(self):
self._install_factory(valid_token="good-token")
auth.save_token("stale-token")

self.assertTrue(auth.logout())
self.assertIsNone(auth.load_token())

def test_other_failure_still_deletes_local_token(self):
fake_cls = _fake_factory_class(valid_token="good-token")

class BoomFactory(fake_cls):
def logout_user(self):
raise RuntimeError("network down")

self._install_factory_class(BoomFactory)
auth.save_token("good-token")

with self.assertRaisesRegex(RuntimeError, "Logout failed"):
auth.logout()

self.assertIsNone(auth.load_token())


class LoginTests(AuthTestCase):
def test_success_caches_token_and_returns_factory(self):
self._install_factory(login_ok=True, login_token="fresh-token")
Expand Down
26 changes: 26 additions & 0 deletions tools/developer_tools/bely-cli/test/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,32 @@ def add_update_log_entry(self, log_entry):
return log_entry


class CmdLogoutTests(unittest.TestCase):
def test_logs_out_current_session(self):
with patch.object(commands.auth, "logout", return_value=True):
buf = io.StringIO()
with redirect_stdout(buf):
commands.cmd_logout()

self.assertEqual(buf.getvalue(), "Logged out.\n")

def test_reports_when_not_logged_in(self):
with patch.object(commands.auth, "logout", return_value=False):
buf = io.StringIO()
with redirect_stdout(buf):
commands.cmd_logout()

self.assertEqual(buf.getvalue(), "Not logged in.\n")

def test_structured_output_reports_status(self):
with patch.object(commands.auth, "logout", return_value=True):
buf = io.StringIO()
with redirect_stdout(buf):
commands.cmd_logout(fmt="json")

self.assertEqual(buf.getvalue(), '{"logged_out": true}\n')


class CmdNewDocTests(unittest.TestCase):
def test_creates_doc_and_first_entry_from_file(self):
api = FakeApi()
Expand Down
32 changes: 31 additions & 1 deletion tools/developer_tools/bely-cli/test/test_tui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ def authenticated_api(self):
def try_token(self):
return self._authenticated

def logout(self):
self._authenticated = False
return True


class FakeLogbookApi:
def get_logbook_type_hierarchy(self):
Expand Down Expand Up @@ -385,7 +389,7 @@ async def test_q_quits_the_app(self):
await pilot.pause()
self.assertIsNone(app.return_value)

async def test_command_palette_offers_config_recent_and_login(self):
async def test_command_palette_offers_config_recent_and_logout_when_authenticated(self):
data = LogbookData(FakeLogbookApi())
app = BelyTuiApp(FakeSession(data), limit=10, mode="app")
async with app.run_test() as pilot:
Expand All @@ -394,7 +398,33 @@ async def test_command_palette_offers_config_recent_and_login(self):
self.assertIn("Configuration", titles)
self.assertIn("My documents", titles)
self.assertIn("Refresh cache", titles)
self.assertIn("Log out", titles)
self.assertNotIn("Log in", titles)

async def test_command_palette_offers_login_when_unauthenticated(self):
data = LogbookData(FakeLogbookApi())
app = BelyTuiApp(FakeSession(data, authenticated=False), limit=10, mode="app")
async with app.run_test() as pilot:
await pilot.pause()
titles = {cmd.title for cmd in app.get_system_commands(app.screen)}
self.assertIn("Log in", titles)
self.assertNotIn("Log out", titles)

async def test_logout_command_clears_session_and_updates_palette(self):
data = LogbookData(FakeLogbookApi())
session = FakeSession(data)
app = BelyTuiApp(session, limit=10, mode="app")
async with app.run_test() as pilot:
await pilot.pause()
app._cmd_logout()
await pilot.pause()
await pilot.pause()

self.assertFalse(session.is_authenticated())
titles = {cmd.title for cmd in app.get_system_commands(app.screen)}
self.assertIn("Log in", titles)
self.assertNotIn("Log out", titles)
self.assertEqual(len(app._notifications), 1)

async def test_search_and_resize_bindings_are_gone(self):
keys = {b.key for b in BrowseScreen.BINDINGS}
Expand Down
Loading