From d6d283b6be9a614915b63e1ca66572a3aaf9d41e Mon Sep 17 00:00:00 2001 From: Peter Amstutz Date: Thu, 31 Oct 2019 14:38:46 -0400 Subject: [PATCH] 15736: "add-session" route, support tokens received from other clusters Refactor sessions to be able to handle searching remote clusters that the user logs in to, instead of using federated token. TODO: direct user to log in and return back when adding with "New cluster". --- src/common/config.ts | 5 +- src/common/url.ts | 9 + src/index.tsx | 3 + src/routes/routes.ts | 3 +- src/services/groups-service/groups-service.ts | 1 + src/store/auth/auth-action-session.ts | 168 ++++++++++-------- .../search-results-middleware-service.ts | 1 + .../add-session/add-session.tsx | 26 +++ .../search-results-panel-view.tsx | 3 +- src/views/workbench/fed-login.tsx | 3 +- 10 files changed, 146 insertions(+), 76 deletions(-) create mode 100644 src/views-components/add-session/add-session.tsx diff --git a/src/common/config.ts b/src/common/config.ts index e5f7b1c6..47723ae8 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -193,5 +193,6 @@ const getDefaultConfig = (): WorkbenchConfig => { }; export const ARVADOS_API_PATH = "arvados/v1"; -export const CLUSTER_CONFIG_URL = "arvados/v1/config"; -export const getClusterConfigURL = (apiHost: string) => `${window.location.protocol}//${apiHost}/${CLUSTER_CONFIG_URL}?nocache=${(new Date()).getTime()}`; +export const CLUSTER_CONFIG_PATH = "arvados/v1/config"; +export const DISCOVERY_DOC_PATH = "discovery/v1/apis/arvados/v1/rest"; +export const getClusterConfigURL = (apiHost: string) => `${window.location.protocol}//${apiHost}/${CLUSTER_CONFIG_PATH}?nocache=${(new Date()).getTime()}`; diff --git a/src/common/url.ts b/src/common/url.ts index 1824f26a..9789b65e 100644 --- a/src/common/url.ts +++ b/src/common/url.ts @@ -4,3 +4,12 @@ export function getUrlParameter(search: string, name: string) { const results = regex.exec(search); return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); } + +export function normalizeURLPath(url: string) { + const u = new URL(url); + u.pathname = u.pathname.replace(/\/\//, '/'); + if (u.pathname[u.pathname.length - 1] === '/') { + u.pathname = u.pathname.substr(0, u.pathname.length - 1); + } + return u.toString(); +} diff --git a/src/index.tsx b/src/index.tsx index f286b7be..5a941638 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -13,6 +13,7 @@ import { History } from "history"; import { configureStore, RootStore } from '~/store/store'; import { ConnectedRouter } from "react-router-redux"; import { ApiToken } from "~/views-components/api-token/api-token"; +import { AddSession } from "~/views-components/add-session/add-session"; import { initAuth } from "~/store/auth/auth-action"; import { createServices } from "~/services/services"; import { MuiThemeProvider } from '@material-ui/core/styles'; @@ -112,6 +113,7 @@ fetchConfig() store.dispatch(loadFileViewersConfig); const TokenComponent = (props: any) => ; + const AddSessionComponent = (props: any) => ; const FedTokenComponent = (props: any) => ; const MainPanelComponent = (props: any) => ; @@ -123,6 +125,7 @@ fetchConfig() + diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 08e0a03d..5cf472c3 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -19,6 +19,7 @@ export const Routes = { ROOT: '/', TOKEN: '/token', FED_LOGIN: '/fedtoken', + ADD_SESSION: '/add-session', PROJECTS: `/projects/:id(${RESOURCE_UUID_PATTERN})`, COLLECTIONS: `/collections/:id(${RESOURCE_UUID_PATTERN})`, PROCESSES: `/processes/:id(${RESOURCE_UUID_PATTERN})`, @@ -159,7 +160,7 @@ export const matchTokenRoute = (route: string) => matchPath(route, { path: Routes.TOKEN }); export const matchFedTokenRoute = (route: string) => - matchPath(route, {path: Routes.FED_LOGIN}); + matchPath(route, { path: Routes.FED_LOGIN }); export const matchUsersRoute = (route: string) => matchPath(route, { path: Routes.USERS }); diff --git a/src/services/groups-service/groups-service.ts b/src/services/groups-service/groups-service.ts index 9517e2cb..691ab8f7 100644 --- a/src/services/groups-service/groups-service.ts +++ b/src/services/groups-service/groups-service.ts @@ -52,6 +52,7 @@ export class GroupsService extends Tras const cfg: AxiosRequestConfig = { params: CommonResourceService.mapKeys(_.snakeCase)(params) }; if (session) { cfg.baseURL = session.baseUrl; + cfg.headers = { 'Authorization': 'Bearer ' + session.token }; } const response = await CommonResourceService.defaultResponse( diff --git a/src/store/auth/auth-action-session.ts b/src/store/auth/auth-action-session.ts index a23fb2ff..b3f625ef 100644 --- a/src/store/auth/auth-action-session.ts +++ b/src/store/auth/auth-action-session.ts @@ -9,36 +9,65 @@ import { ServiceRepository } from "~/services/services"; import Axios from "axios"; import { getUserFullname, User } from "~/models/user"; import { authActions } from "~/store/auth/auth-action"; -import { Config, ClusterConfigJSON, CLUSTER_CONFIG_URL, ARVADOS_API_PATH } from "~/common/config"; +import { Config, ClusterConfigJSON, CLUSTER_CONFIG_PATH, DISCOVERY_DOC_PATH, ARVADOS_API_PATH } from "~/common/config"; +import { normalizeURLPath } from "~/common/url"; import { Session, SessionStatus } from "~/models/session"; import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions"; import { AuthService, UserDetailsResponse } from "~/services/auth-service/auth-service"; import * as jsSHA from "jssha"; -const getRemoteHostBaseUrl = async (remoteHost: string): Promise => { +const getClusterInfo = async (origin: string): Promise<{ clusterId: string, baseURL: string } | null> => { + // Try the new public config endpoint + try { + const config = (await Axios.get(`${origin}/${CLUSTER_CONFIG_PATH}`)).data; + return { + clusterId: config.ClusterID, + baseURL: normalizeURLPath(`${config.Services.Controller.ExternalURL}/${ARVADOS_API_PATH}`) + }; + } catch { } + + // Fall back to discovery document + try { + const config = (await Axios.get(`${origin}/${DISCOVERY_DOC_PATH}`)).data; + return { + clusterId: config.uuidPrefix, + baseURL: normalizeURLPath(config.baseUrl) + }; + } catch { } + + return null; +}; + +const getRemoteHostInfo = async (remoteHost: string): Promise<{ clusterId: string, baseURL: string } | null> => { let url = remoteHost; if (url.indexOf('://') < 0) { url = 'https://' + url; } const origin = new URL(url).origin; - let baseUrl: string | null = null; + // Maybe it is an API server URL, try fetching config and discovery doc + let r = getClusterInfo(origin); + if (r !== null) { + return r; + } + + // Maybe it is a Workbench2 URL, try getting config.json try { - const resp = await Axios.get(`${origin}/${CLUSTER_CONFIG_URL}`); - baseUrl = `${resp.data.Services.Controller.ExternalURL}/${ARVADOS_API_PATH}`; - } catch (err) { - try { - const resp = await Axios.get(`${origin}/status.json`); - baseUrl = resp.data.apiBaseURL; - } catch (err) { + r = getClusterInfo((await Axios.get(`${origin}/config.json`)).data.API_HOST); + if (r !== null) { + return r; } - } + } catch { } - if (baseUrl && baseUrl[baseUrl.length - 1] === '/') { - baseUrl = baseUrl.substr(0, baseUrl.length - 1); - } + // Maybe it is a Workbench1 URL, try getting status.json + try { + r = getClusterInfo((await Axios.get(`${origin}/status.json`)).data.apiBaseURL); + if (r !== null) { + return r; + } + } catch { } - return baseUrl; + return null; }; const getUserDetails = async (baseUrl: string, token: string): Promise => { @@ -50,41 +79,34 @@ const getUserDetails = async (baseUrl: string, token: string): Promise => { - if (token.startsWith("v2/")) { - const uuid = token.split("/")[1]; - return Promise.resolve(uuid); +export const getSaltedToken = (clusterId: string, token: string) => { + const shaObj = new jsSHA("SHA-1", "TEXT"); + const [ver, uuid, secret] = token.split("/"); + if (ver !== "v2") { + throw new Error("Must be a v2 token"); + } + let salted = secret; + if (uuid.substr(0, 5) !== clusterId) { + shaObj.setHMACKey(secret, "TEXT"); + shaObj.update(clusterId); + salted = shaObj.getHMAC("HEX"); } + return `v2/${uuid}/${salted}`; +}; - const resp = await Axios.get(`${baseUrl}api_client_authorizations`, { - headers: { - Authorization: `OAuth2 ${token}` - }, - data: { - filters: JSON.stringify([['api_token', '=', token]]) - } - }); +export const getActiveSession = (sessions: Session[]): Session | undefined => sessions.find(s => s.active); - return resp.data.items[0].uuid; -}; +export const validateCluster = async (remoteHost: string, useToken: string): + Promise<{ user: User; token: string, baseUrl: string, clusterId: string }> => { -export const getSaltedToken = (clusterId: string, tokenUuid: string, token: string) => { - const shaObj = new jsSHA("SHA-1", "TEXT"); - let secret = token; - if (token.startsWith("v2/")) { - secret = token.split("/")[2]; + const info = await getRemoteHostInfo(remoteHost); + if (!info) { + return Promise.reject(`Could not get config for ${remoteHost}`); } - shaObj.setHMACKey(secret, "TEXT"); - shaObj.update(clusterId); - const hmac = shaObj.getHMAC("HEX"); - return `v2/${tokenUuid}/${hmac}`; -}; - -const clusterLogin = async (clusterId: string, baseUrl: string, activeSession: Session): Promise<{ user: User, token: string }> => { - const tokenUuid = await getTokenUuid(activeSession.baseUrl, activeSession.token); - const saltedToken = getSaltedToken(clusterId, tokenUuid, activeSession.token); - const user = await getUserDetails(baseUrl, saltedToken); + const saltedToken = getSaltedToken(info.clusterId, useToken); + const user = await getUserDetails(info.baseURL, saltedToken); return { + baseUrl: info.baseURL, user: { firstName: user.first_name, lastName: user.last_name, @@ -96,39 +118,38 @@ const clusterLogin = async (clusterId: string, baseUrl: string, activeSession: S username: user.username, prefs: user.prefs }, - token: saltedToken + token: saltedToken, + clusterId: info.clusterId }; }; -export const getActiveSession = (sessions: Session[]): Session | undefined => sessions.find(s => s.active); - -export const validateCluster = async (remoteHost: string, clusterId: string, activeSession: Session): Promise<{ user: User; token: string, baseUrl: string }> => { - const baseUrl = await getRemoteHostBaseUrl(remoteHost); - if (!baseUrl) { - return Promise.reject(`Could not find base url for ${remoteHost}`); - } - const { user, token } = await clusterLogin(clusterId, baseUrl, activeSession); - return { baseUrl, user, token }; -}; - export const validateSession = (session: Session, activeSession: Session) => async (dispatch: Dispatch): Promise => { dispatch(authActions.UPDATE_SESSION({ ...session, status: SessionStatus.BEING_VALIDATED })); session.loggedIn = false; - try { - const { baseUrl, user, token } = await validateCluster(session.remoteHost, session.clusterId, activeSession); + + const setupSession = (baseUrl: string, user: User, token: string) => { session.baseUrl = baseUrl; session.token = token; session.email = user.email; session.uuid = user.uuid; session.name = getUserFullname(user); session.loggedIn = true; + }; + + try { + const { baseUrl, user, token } = await validateCluster(session.remoteHost, session.token); + setupSession(baseUrl, user, token); } catch { - session.loggedIn = false; - } finally { - session.status = SessionStatus.VALIDATED; - dispatch(authActions.UPDATE_SESSION(session)); + try { + const { baseUrl, user, token } = await validateCluster(session.remoteHost, activeSession.token); + setupSession(baseUrl, user, token); + } catch { } } + + session.status = SessionStatus.VALIDATED; + dispatch(authActions.UPDATE_SESSION(session)); + return session; }; @@ -148,17 +169,20 @@ export const validateSessions = () => } }; -export const addSession = (remoteHost: string) => +export const addSession = (remoteHost: string, token?: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const sessions = getState().auth.sessions; const activeSession = getActiveSession(sessions); - if (activeSession) { - const clusterId = remoteHost.match(/^(\w+)\./)![1]; - if (sessions.find(s => s.clusterId === clusterId)) { - return Promise.reject("Cluster already exists"); - } + let useToken: string | null = null; + if (token) { + useToken = token; + } else if (activeSession) { + useToken = activeSession.token; + } + + if (useToken) { try { - const { baseUrl, user, token } = await validateCluster(remoteHost, clusterId, activeSession); + const { baseUrl, user, token, clusterId } = await validateCluster(remoteHost, useToken); const session = { loggedIn: true, status: SessionStatus.VALIDATED, @@ -172,7 +196,11 @@ export const addSession = (remoteHost: string) => token }; - dispatch(authActions.ADD_SESSION(session)); + if (sessions.find(s => s.clusterId === clusterId)) { + dispatch(authActions.UPDATE_SESSION(session)); + } else { + dispatch(authActions.ADD_SESSION(session)); + } services.authService.saveSessions(getState().auth.sessions); return session; diff --git a/src/store/search-results-panel/search-results-middleware-service.ts b/src/store/search-results-panel/search-results-middleware-service.ts index 3233525a..85f12c3f 100644 --- a/src/store/search-results-panel/search-results-middleware-service.ts +++ b/src/store/search-results-panel/search-results-middleware-service.ts @@ -45,6 +45,7 @@ export class SearchResultsMiddlewareService extends DataExplorerMiddlewareServic try { const params = getParams(dataExplorer, searchValue); + // TODO: if one of these fails, no results will be returned. const responses = await Promise.all(sessions.map(session => this.services.groupsService.contents('', params, session) )); diff --git a/src/views-components/add-session/add-session.tsx b/src/views-components/add-session/add-session.tsx new file mode 100644 index 00000000..4628e1ce --- /dev/null +++ b/src/views-components/add-session/add-session.tsx @@ -0,0 +1,26 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { RouteProps } from "react-router"; +import * as React from "react"; +import { connect, DispatchProp } from "react-redux"; +import { getUrlParameter } from "~/common/url"; +import { navigateToSiteManager } from "~/store/navigation/navigation-action"; +import { addSession } from "~/store/auth/auth-action-session"; + +export const AddSession = connect()( + class extends React.Component, {}> { + componentDidMount() { + const search = this.props.location ? this.props.location.search : ""; + const apiToken = getUrlParameter(search, 'api_token'); + const baseURL = getUrlParameter(search, 'baseURL'); + + this.props.dispatch(addSession(baseURL, apiToken)); + this.props.dispatch(navigateToSiteManager); + } + render() { + return
; + } + } +); diff --git a/src/views/search-results-panel/search-results-panel-view.tsx b/src/views/search-results-panel/search-results-panel-view.tsx index 6eac09fa..8bc5419b 100644 --- a/src/views/search-results-panel/search-results-panel-view.tsx +++ b/src/views/search-results-panel/search-results-panel-view.tsx @@ -127,7 +127,8 @@ export const SearchResultsPanelView = withStyles(styles, { withTheme: true })( {loggedIn.length === 1 ? Searching local cluster : Searching clusters: {loggedIn.map((ss) => - )}} + + )}} {loggedIn.length === 1 && props.localCluster !== homeCluster ? To search multiple clusters, start from your home Workbench. : Use Site Manager to manage which clusters will be searched.} diff --git a/src/views/workbench/fed-login.tsx b/src/views/workbench/fed-login.tsx index be543a64..7c8b87c7 100644 --- a/src/views/workbench/fed-login.tsx +++ b/src/views/workbench/fed-login.tsx @@ -30,7 +30,6 @@ export const FedLogin = connect(mapStateToProps)( if (!apiToken || !user || !user.uuid.startsWith(localCluster)) { return <>; } - const [, tokenUuid, token] = apiToken.split("/"); return
{Object.keys(remoteHostsConfig) .map((k) => { @@ -42,7 +41,7 @@ export const FedLogin = connect(mapStateToProps)( return; } const fedtoken = (remoteHostsConfig[k].loginCluster === localCluster) - ? apiToken : getSaltedToken(k, tokenUuid, token); + ? apiToken : getSaltedToken(k, apiToken); return