From a8907d3a56625015f949e4bfddbb8395b308454d Mon Sep 17 00:00:00 2001 From: Tadayoshi Sato Date: Thu, 26 Oct 2023 18:42:20 +0900 Subject: [PATCH] feat(connect): provide login form for connecting to authenticated remote jolokia Fix #482 --- packages/hawtio/src/core/core.ts | 3 + .../hawtio/src/plugins/connect/Connect.tsx | 126 ++++++++++-------- packages/hawtio/src/plugins/connect/init.ts | 9 +- .../plugins/connect/login/ConnectLogin.tsx | 75 +++++++++++ .../shared/__mocks__/connect-service.ts | 12 ++ .../src/plugins/shared/connect-service.ts | 102 ++++++++++++-- .../src/plugins/shared/jolokia-service.ts | 7 +- 7 files changed, 260 insertions(+), 74 deletions(-) create mode 100644 packages/hawtio/src/plugins/connect/login/ConnectLogin.tsx diff --git a/packages/hawtio/src/core/core.ts b/packages/hawtio/src/core/core.ts index 74c9d4ab..9de41ac5 100644 --- a/packages/hawtio/src/core/core.ts +++ b/packages/hawtio/src/core/core.ts @@ -271,6 +271,9 @@ class HawtioCore { resolved.push(plugin) } } + + log.debug('Resolved plugins:', resolved) + return resolved } } diff --git a/packages/hawtio/src/plugins/connect/Connect.tsx b/packages/hawtio/src/plugins/connect/Connect.tsx index b192763d..abdb88b7 100644 --- a/packages/hawtio/src/plugins/connect/Connect.tsx +++ b/packages/hawtio/src/plugins/connect/Connect.tsx @@ -16,7 +16,6 @@ import { Modal, ModalVariant, PageSection, - PageSectionVariants, Text, TextContent, Toolbar, @@ -30,78 +29,89 @@ import { ConnectModal } from './ConnectModal' import { DELETE } from './connections' import { ConnectContext, useConnections } from './context' import { log } from './globals' +import { Route, Routes } from 'react-router-dom' +import { ConnectLogin } from './login/ConnectLogin' export const Connect: React.FunctionComponent = () => { const { connections, dispatch } = useConnections() - log.debug('Connections:', connections) - - const ConnectHint = () => ( - - Hint - - } - > - - This page allows you to connect to remote processes which{' '} - - already have a{' '} - - Jolokia agent - {' '} - running inside them - - . You will need to know the host name, port and path of the Jolokia agent to be able to connect. - - - If the process you wish to connect to does not have a Jolokia agent inside, please refer to the{' '} - - Jolokia documentation - {' '} - for how to add a JVM, servlet or OSGi based agent inside it. - - - If you are using{' '} - - Red Hat Fuse{' '} - - or{' '} - - Apache ActiveMQ - - , then a Jolokia agent is included by default (use context path of Jolokia agent, usually - jolokia). Or you can always just deploy hawtio inside the process (which includes the Jolokia - agent, use Jolokia servlet mapping inside hawtio context path, usually hawtio/jolokia). - - - ) - - const ConnectionList = () => ( - - {Object.entries(connections).map(([name, connection]) => ( - - ))} - - ) return ( - + Connect - - - + + + } /> + } /> + ) } +const ConnectHint: React.FunctionComponent = () => ( + + Hint + + } + > + + This page allows you to connect to remote processes which{' '} + + already have a{' '} + + Jolokia agent + {' '} + running inside them + + . You will need to know the host name, port and path of the Jolokia agent to be able to connect. + + + If the process you wish to connect to does not have a Jolokia agent inside, please refer to the{' '} + + Jolokia documentation + {' '} + for how to add a JVM, servlet or OSGi based agent inside it. + + + If you are using{' '} + + Red Hat Fuse{' '} + + or{' '} + + Apache ActiveMQ + + , then a Jolokia agent is included by default (use context path of Jolokia agent, usually + jolokia). Or you can always just deploy hawtio inside the process (which includes the Jolokia agent, + use Jolokia servlet mapping inside hawtio context path, usually hawtio/jolokia). + + +) + +const ConnectContent: React.FunctionComponent = () => { + const { connections } = useContext(ConnectContext) + log.debug('Connections:', connections) + + return ( + + + + {Object.entries(connections).map(([name, connection]) => ( + + ))} + + + ) +} + const ConnectToolbar: React.FunctionComponent = () => { const { connections } = useContext(ConnectContext) const [isAddOpen, setIsAddOpen] = useState(false) @@ -198,7 +208,7 @@ const ConnectionItem: React.FunctionComponent = props => { return } - log.debug('Collecting:', connection) + log.debug('Connecting:', connection) connectService.connect(connection) } diff --git a/packages/hawtio/src/plugins/connect/init.ts b/packages/hawtio/src/plugins/connect/init.ts index fb6ca483..e6e79509 100644 --- a/packages/hawtio/src/plugins/connect/init.ts +++ b/packages/hawtio/src/plugins/connect/init.ts @@ -7,7 +7,9 @@ export async function isActive(): Promise { return false } - return connectService.getCurrentConnectionName() === null + // The connect login path is exceptionally allowlisted to provide login form for + // remote Jolokia endpoints requiring authentication. + return connectService.getCurrentConnectionName() === null || isConnectLogin() } async function isProxyEnabled(): Promise { @@ -30,3 +32,8 @@ async function isProxyEnabled(): Promise { return true } } + +function isConnectLogin(): boolean { + const url = new URL(window.location.href) + return url.pathname === connectService.getLoginPath() +} diff --git a/packages/hawtio/src/plugins/connect/login/ConnectLogin.tsx b/packages/hawtio/src/plugins/connect/login/ConnectLogin.tsx new file mode 100644 index 00000000..3f9c2f6c --- /dev/null +++ b/packages/hawtio/src/plugins/connect/login/ConnectLogin.tsx @@ -0,0 +1,75 @@ +import { connectService } from '@hawtiosrc/plugins/shared' +import { Alert, Button, Form, FormAlert, FormGroup, Modal, TextInput } from '@patternfly/react-core' +import React, { useState } from 'react' + +export const ConnectLogin: React.FunctionComponent = () => { + const [isOpen, setIsOpen] = useState(true) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [loginFailed, setLoginFailed] = useState(false) + + const connectionName = connectService.getCurrentConnectionName() + if (!connectionName) { + return null + } + + const handleLogin = () => { + const login = async () => { + const ok = await connectService.login(username, password) + if (ok) { + setLoginFailed(false) + // Redirect to the original URL + connectService.redirect() + } else { + setLoginFailed(true) + } + } + login() + } + + const handleClose = () => { + setIsOpen(false) + } + + const actions = [ + , + , + ] + + const title = `Log in to ${connectionName}` + + return ( + +
+ {loginFailed && ( + + + + )} + + setUsername(value)} + /> + + + setPassword(value)} + /> + +
+
+ ) +} diff --git a/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts b/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts index 5d3c8e47..2866c6bd 100644 --- a/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts +++ b/packages/hawtio/src/plugins/shared/__mocks__/connect-service.ts @@ -42,6 +42,14 @@ class MockConnectService implements IConnectService { // no-op } + async login(username: string, password: string): Promise { + return false + } + + redirect() { + // no-op + } + getJolokiaUrl(connection: Connection): string { return '' } @@ -50,6 +58,10 @@ class MockConnectService implements IConnectService { return null } + getLoginPath(): string { + return '' + } + export(connections: Connections) { // no-op } diff --git a/packages/hawtio/src/plugins/shared/connect-service.ts b/packages/hawtio/src/plugins/shared/connect-service.ts index e291bc4f..fd2a5224 100644 --- a/packages/hawtio/src/plugins/shared/connect-service.ts +++ b/packages/hawtio/src/plugins/shared/connect-service.ts @@ -1,4 +1,4 @@ -import { hawtio } from '@hawtiosrc/core' +import { eventService, hawtio } from '@hawtiosrc/core' import { toString } from '@hawtiosrc/util/strings' import { joinPaths } from '@hawtiosrc/util/urls' import Jolokia from 'jolokia.js' @@ -27,10 +27,19 @@ export type ConnectionTestResult = { message: string } +type ConnectionCredentials = { + username: string + password: string +} + const STORAGE_KEY_CONNECTIONS = 'connect.connections' const SESSION_KEY_CURRENT_CONNECTION = 'connect.currentConnection' +const SESSION_KEY_CREDENTIALS = 'connect.credentials' export const PARAM_KEY_CONNECTION = 'con' +export const PARAM_KEY_REDIRECT = 'redirect' + +const LOGIN_PATH = '/connect/login' export interface IConnectService { getCurrentConnectionName(): string | null @@ -42,8 +51,11 @@ export interface IConnectService { checkReachable(connection: Connection): Promise testConnection(connection: Connection): Promise connect(connection: Connection): void + login(username: string, password: string): Promise + redirect(): void getJolokiaUrl(connection: Connection): string getJolokiaUrlFromName(name: string): string | null + getLoginPath(): string export(connections: Connections): void } @@ -75,7 +87,26 @@ class ConnectService implements IConnectService { } getCurrentConnection(): Connection | null { - return this.currentConnection ? this.getConnection(this.currentConnection) : null + const conn = this.currentConnection ? this.getConnection(this.currentConnection) : null + if (!conn) { + return null + } + + // Apply credentials if it exists + const item = sessionStorage.getItem(SESSION_KEY_CREDENTIALS) + if (!item) { + return conn + } + const credentials = JSON.parse(item) as ConnectionCredentials + conn.username = credentials.username + conn.password = credentials.password + this.clearCredentialsOnLogout() + + return conn + } + + private clearCredentialsOnLogout() { + eventService.onLogout(() => sessionStorage.removeItem(SESSION_KEY_CREDENTIALS)) } loadConnections(): Connections { @@ -141,14 +172,67 @@ class ConnectService implements IConnectService { }) } + private forbiddenReasonMatches(response: JQueryXHR, reason: string): boolean { + // Preserve compatibility with versions of Hawtio 2.x that return JSON on 403 responses + if (response.responseJSON && response.responseJSON['reason']) { + return response.responseJSON['reason'] === reason + } + // Otherwise expect a response header containing a forbidden reason + return response.getResponseHeader('Hawtio-Forbidden-Reason') === reason + } + connect(connection: Connection) { log.debug('Connecting with options:', toString(connection)) - const basepath = hawtio.getBasePath() ?? '/' - const url = `${basepath}?${PARAM_KEY_CONNECTION}=${connection.name}` + const basepath = hawtio.getBasePath() ?? '' + const url = `${basepath}/?${PARAM_KEY_CONNECTION}=${connection.name}` log.debug('Opening URL:', url) window.open(url) } + /** + * Log in to the current connection. + */ + async login(username: string, password: string): Promise { + const connection = this.getCurrentConnection() + if (!connection) { + return false + } + + // Check credentials + const ok = await new Promise(resolve => { + connection.username = username + connection.password = password + this.createJolokia(connection, true).request( + { type: 'version' }, + { + success: () => resolve(true), + error: () => resolve(false), + ajaxError: () => resolve(false), + }, + ) + }) + if (!ok) { + return false + } + + // Persist credentials to session storage + const credentials: ConnectionCredentials = { username, password } + sessionStorage.setItem(SESSION_KEY_CREDENTIALS, JSON.stringify(credentials)) + this.clearCredentialsOnLogout() + + return true + } + + /** + * Redirect to the URL specified in the query parameter {@link PARAM_KEY_REDIRECT}. + */ + redirect() { + const url = new URL(window.location.href) + const redirect = url.searchParams.get(PARAM_KEY_REDIRECT) ?? hawtio.getBasePath() ?? '/' + log.debug('Redirect to:', redirect) + window.location.href = redirect + } + /** * Create a Jolokia instance with the given connection. */ @@ -200,13 +284,9 @@ class ConnectService implements IConnectService { return connection ? this.getJolokiaUrl(connection) : null } - private forbiddenReasonMatches(response: JQueryXHR, reason: string): boolean { - // Preserve compatibility with versions of Hawtio 2.x that return JSON on 403 responses - if (response.responseJSON && response.responseJSON['reason']) { - return response.responseJSON['reason'] === reason - } - // Otherwise expect a response header containing a forbidden reason - return response.getResponseHeader('Hawtio-Forbidden-Reason') === reason + getLoginPath(): string { + const basePath = hawtio.getBasePath() + return `${basePath}${LOGIN_PATH}` } export(connections: Connections) { diff --git a/packages/hawtio/src/plugins/shared/jolokia-service.ts b/packages/hawtio/src/plugins/shared/jolokia-service.ts index f9e4cfb1..d36631da 100644 --- a/packages/hawtio/src/plugins/shared/jolokia-service.ts +++ b/packages/hawtio/src/plugins/shared/jolokia-service.ts @@ -30,7 +30,7 @@ import Jolokia, { import 'jolokia.js/simple' import $ from 'jquery' import { func, is, object } from 'superstruct' -import { PARAM_KEY_CONNECTION, connectService } from '../shared/connect-service' +import { PARAM_KEY_CONNECTION, PARAM_KEY_REDIRECT, connectService } from '../shared/connect-service' import { log } from './globals' export const DEFAULT_MAX_DEPTH = 7 @@ -301,14 +301,13 @@ class JolokiaService implements IJolokiaService { const url = new URL(window.location.href) // If window was opened to connect to remote Jolokia endpoint if (url.searchParams.has(PARAM_KEY_CONNECTION)) { - const basePath = hawtio.getBasePath() - const loginPath = `${basePath}/connect/login` + const loginPath = connectService.getLoginPath() if (url.pathname !== loginPath) { // ... and not showing the login modal this.jolokia?.then(jolokia => jolokia.stop()) const redirectUrl = window.location.href url.pathname = loginPath - url.searchParams.append('redirect', redirectUrl) + url.searchParams.append(PARAM_KEY_REDIRECT, redirectUrl) window.location.href = url.href } } else {