From: Daniel Kos Date: Tue, 18 Dec 2018 07:03:33 +0000 (+0100) Subject: Add session validation X-Git-Tag: 1.4.0~71^2~16^2^2^2~5 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/2a4f0a7d69cb0cb94b43a05ddff91e4cd06c6c39 Add session validation Feature #14478 Arvados-DCO-1.1-Signed-off-by: Daniel Kos --- diff --git a/package.json b/package.json index 13326304..46b3ee4f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@material-ui/icons": "3.0.1", "@types/debounce": "3.0.0", "@types/js-yaml": "3.11.2", + "@types/jssha": "0.0.29", "@types/lodash": "4.14.116", "@types/react-copy-to-clipboard": "4.2.6", "@types/react-dnd": "3.0.2", @@ -21,6 +22,7 @@ "debounce": "1.2.0", "is-image": "2.0.0", "js-yaml": "3.12.0", + "jssha": "2.3.1", "lodash": "4.17.11", "react": "16.5.2", "react-copy-to-clipboard": "5.0.1", diff --git a/src/components/text-field/text-field.tsx b/src/components/text-field/text-field.tsx index 0aeaeb85..0ebb46bc 100644 --- a/src/components/text-field/text-field.tsx +++ b/src/components/text-field/text-field.tsx @@ -26,7 +26,7 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ type TextFieldProps = WrappedFieldProps & WithStyles; export const TextField = withStyles(styles)((props: TextFieldProps & { - label?: string, autoFocus?: boolean, required?: boolean, select?: boolean, children: React.ReactNode, margin?: Margin + label?: string, autoFocus?: boolean, required?: boolean, select?: boolean, children: React.ReactNode, margin?: Margin, placeholder?: string }) => ); diff --git a/src/models/session.ts b/src/models/session.ts index 8a5002be..9a942967 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -1,9 +1,21 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +export enum SessionStatus { + INVALIDATED, + BEING_VALIDATED, + VALIDATED +} + export interface Session { clusterId: string; remoteHost: string; + baseUrl: string; username: string; email: string; token: string; loggedIn: boolean; - validated: boolean; + status: SessionStatus; + active: boolean; } diff --git a/src/services/auth-service/auth-service.ts b/src/services/auth-service/auth-service.ts index ffd81ef1..1492ef1c 100644 --- a/src/services/auth-service/auth-service.ts +++ b/src/services/auth-service/auth-service.ts @@ -6,9 +6,9 @@ import { getUserFullname, User } from "~/models/user"; import { AxiosInstance } from "axios"; import { ApiActions } from "~/services/api/api-actions"; import * as uuid from "uuid/v4"; -import { Session } from "~/models/session"; +import { Session, SessionStatus } from "~/models/session"; import { Config } from "~/common/config"; -import { merge, uniqWith, uniqBy } from "lodash"; +import { uniqBy } from "lodash"; export const API_TOKEN_KEY = 'apiToken'; export const USER_EMAIL_KEY = 'userEmail'; @@ -144,11 +144,14 @@ export class AuthService { public buildSessions(cfg: Config, user?: User) { const currentSession = { clusterId: cfg.uuidPrefix, - remoteHost: cfg.baseUrl, + remoteHost: cfg.rootUrl, + baseUrl: cfg.baseUrl, username: getUserFullname(user), email: user ? user.email : '', token: this.getApiToken(), - loggedIn: true + loggedIn: true, + active: true, + status: SessionStatus.VALIDATED } as Session; const localSessions = this.getSessions(); const cfgSessions = Object.keys(cfg.remoteHosts).map(clusterId => { @@ -156,15 +159,18 @@ export class AuthService { return { clusterId, remoteHost, + baseUrl: '', username: '', email: '', token: '', - loggedIn: false + loggedIn: false, + active: false, + status: SessionStatus.INVALIDATED } as Session; }); const sessions = [currentSession] - .concat(cfgSessions) - .concat(localSessions); + .concat(localSessions) + .concat(cfgSessions); const uniqSessions = uniqBy(sessions, 'clusterId'); diff --git a/src/store/auth/auth-action-session.ts b/src/store/auth/auth-action-session.ts index c70bcfbb..6ffca6b5 100644 --- a/src/store/auth/auth-action-session.ts +++ b/src/store/auth/auth-action-session.ts @@ -1,37 +1,48 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + import { Dispatch } from "redux"; import { setBreadcrumbs } from "~/store/breadcrumbs/breadcrumbs-actions"; import { RootState } from "~/store/store"; import { ServiceRepository } from "~/services/services"; import Axios from "axios"; -import { getUserFullname } from "~/models/user"; +import { getUserFullname, User } from "~/models/user"; import { authActions } from "~/store/auth/auth-action"; import { Config, DISCOVERY_URL } from "~/common/config"; -import { Session } from "~/models/session"; +import { Session, SessionStatus } from "~/models/session"; import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions"; import { UserDetailsResponse } from "~/services/auth-service/auth-service"; +import * as jsSHA from "jssha"; - -const getSessionOrigin = async (session: Session) => { - let url = session.remoteHost; +const getRemoteHostBaseUrl = async (remoteHost: string): Promise => { + let url = remoteHost; if (url.indexOf('://') < 0) { url = 'https://' + url; } const origin = new URL(url).origin; + let baseUrl: string | null = null; + try { const resp = await Axios.get(`${origin}/${DISCOVERY_URL}`); - return resp.data.origin; + baseUrl = resp.data.baseUrl; } catch (err) { try { const resp = await Axios.get(`${origin}/status.json`); - return resp.data.apiBaseURL; + baseUrl = resp.data.apiBaseURL; } catch (err) { } } - return null; + + if (baseUrl && baseUrl[baseUrl.length - 1] === '/') { + baseUrl = baseUrl.substr(0, baseUrl.length - 1); + } + + return baseUrl; }; -const getUserDetails = async (origin: string, token: string): Promise => { - const resp = await Axios.get(`${origin}/arvados/v1/users/current`, { +const getUserDetails = async (baseUrl: string, token: string): Promise => { + const resp = await Axios.get(`${baseUrl}/users/current`, { headers: { Authorization: `OAuth2 ${token}` } @@ -39,35 +50,135 @@ const getUserDetails = async (origin: string, token: string): Promise +const getTokenUuid = async (baseUrl: string, token: string): Promise => { + if (token.startsWith("v2/")) { + const uuid = token.split("/")[1]; + return Promise.resolve(uuid); + } + + const resp = await Axios.get(`${baseUrl}/api_client_authorizations`, { + headers: { + Authorization: `OAuth2 ${token}` + }, + data: { + filters: JSON.stringify([['api_token', '=', token]]) + } + }); + + return resp.data.items[0].uuid; +}; + +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]; + } + 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); + console.log(">> Cluster", clusterId); + const saltedToken = getSaltedToken(clusterId, tokenUuid, activeSession.token); + console.log(">> Salted token", saltedToken); + const user = await getUserDetails(baseUrl, saltedToken); + return { + user: { + firstName: user.first_name, + lastName: user.last_name, + uuid: user.uuid, + ownerUuid: user.owner_uuid, + email: user.email, + isAdmin: user.is_admin + }, + token: saltedToken + }; +}; + +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) => { + dispatch(authActions.UPDATE_SESSION({ ...session, status: SessionStatus.BEING_VALIDATED })); + session.loggedIn = false; + try { + const { baseUrl, user, token } = await validateCluster(session.remoteHost, session.clusterId, activeSession); + session.baseUrl = baseUrl; + session.token = token; + session.email = user.email; + session.username = getUserFullname(user); + session.loggedIn = true; + } catch { + session.loggedIn = false; + } finally { + session.status = SessionStatus.VALIDATED; + dispatch(authActions.UPDATE_SESSION(session)); + } + return session; + }; + +export const validateSessions = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const sessions = getState().auth.sessions; - dispatch(progressIndicatorActions.START_WORKING("sessionsValidation")); - for (const session of sessions) { - if (!session.validated) { - const origin = await getSessionOrigin(session); - const user = await getUserDetails(origin, session.token); + const activeSession = getActiveSession(sessions); + if (activeSession) { + dispatch(progressIndicatorActions.START_WORKING("sessionsValidation")); + for (const session of sessions) { + if (session.status === SessionStatus.INVALIDATED) { + await dispatch(validateSession(session, activeSession)); + } } + services.authService.saveSessions(sessions); + dispatch(progressIndicatorActions.STOP_WORKING("sessionsValidation")); } - dispatch(progressIndicatorActions.STOP_WORKING("sessionsValidation")); }; export const addSession = (remoteHost: string) => - (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const user = getState().auth.user!; - const clusterId = remoteHost.match(/^(\w+)\./)![1]; + 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"); + } + try { + const { baseUrl, user, token } = await dispatch(validateCluster(remoteHost, clusterId, activeSession)); + const session = { + loggedIn: false, + status: SessionStatus.VALIDATED, + active: false, + email: user.email, + username: getUserFullname(user), + remoteHost, + baseUrl, + clusterId, + token + }; - dispatch(authActions.ADD_SESSION({ - loggedIn: false, - validated: false, - email: user.email, - username: getUserFullname(user), - remoteHost, - clusterId, - token: '' - })); + dispatch(authActions.ADD_SESSION(session)); + services.authService.saveSessions(getState().auth.sessions); - services.authService.saveSessions(getState().auth.sessions); + return session; + } catch (e) { + console.error(e); + } + } + debugger; + return Promise.reject("Could not validate cluster"); }; export const loadSiteManagerPanel = () => diff --git a/src/store/auth/auth-action-ssh.ts b/src/store/auth/auth-action-ssh.ts index 4175d295..2c3a2722 100644 --- a/src/store/auth/auth-action-ssh.ts +++ b/src/store/auth/auth-action-ssh.ts @@ -1,3 +1,7 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + import { dialogActions } from "~/store/dialog/dialog-actions"; import { Dispatch } from "redux"; import { RootState } from "~/store/store"; diff --git a/src/store/auth/auth-action.ts b/src/store/auth/auth-action.ts index 8c1673d7..ed998ab5 100644 --- a/src/store/auth/auth-action.ts +++ b/src/store/auth/auth-action.ts @@ -24,7 +24,8 @@ export const authActions = unionize({ REMOVE_SSH_KEY: ofType(), SET_SESSIONS: ofType(), ADD_SESSION: ofType(), - REMOVE_SESSION: ofType() + REMOVE_SESSION: ofType(), + UPDATE_SESSION: ofType() }); function setAuthorizationHeader(services: ServiceRepository, token: string) { diff --git a/src/store/auth/auth-reducer.ts b/src/store/auth/auth-reducer.ts index 1edcedee..a2822f10 100644 --- a/src/store/auth/auth-reducer.ts +++ b/src/store/auth/auth-reducer.ts @@ -61,6 +61,13 @@ export const authReducer = (services: ServiceRepository) => (state = initialStat session => session.clusterId !== clusterId )}; }, + UPDATE_SESSION: (session: Session) => { + return { + ...state, + sessions: state.sessions.map( + s => s.clusterId === session.clusterId ? session : s + )}; + }, default: () => state }); }; diff --git a/src/views/site-manager-panel/site-manager-panel-root.tsx b/src/views/site-manager-panel/site-manager-panel-root.tsx index 29969fc5..a64fdb25 100644 --- a/src/views/site-manager-panel/site-manager-panel-root.tsx +++ b/src/views/site-manager-panel/site-manager-panel-root.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { Card, - CardContent, + CardContent, CircularProgress, Grid, StyleRulesCallback, Table, @@ -18,14 +18,18 @@ import { withStyles } from '@material-ui/core'; import { ArvadosTheme } from '~/common/custom-theme'; -import { Session } from "~/models/session"; +import { Session, SessionStatus } from "~/models/session"; import Button from "@material-ui/core/Button"; import { User } from "~/models/user"; import { compose } from "redux"; -import { Field, InjectedFormProps, reduxForm, reset } from "redux-form"; +import { Field, FormErrors, InjectedFormProps, reduxForm, reset, stopSubmit } from "redux-form"; import { TextField } from "~/components/text-field/text-field"; import { addSession } from "~/store/auth/auth-action-session"; import { SITE_MANAGER_REMOTE_HOST_VALIDATION } from "~/validators/validators"; +import { + RENAME_FILE_DIALOG, + RenameFileDialogData +} from "~/store/collection-panel/collection-panel-files/collection-panel-files-actions"; type CssRules = 'root' | 'link' | 'buttonContainer' | 'table' | 'tableRow' | 'status' | 'remoteSiteInfo' | 'buttonAdd'; @@ -80,9 +84,17 @@ const SITE_MANAGER_FORM_NAME = 'siteManagerForm'; export const SiteManagerPanelRoot = compose( reduxForm<{remoteHost: string}>({ form: SITE_MANAGER_FORM_NAME, - onSubmit: (data, dispatch) => { - dispatch(addSession(data.remoteHost)); - dispatch(reset(SITE_MANAGER_FORM_NAME)); + onSubmit: async (data, dispatch) => { + try { + await dispatch(addSession(data.remoteHost)); + dispatch(reset(SITE_MANAGER_FORM_NAME)); + } catch (e) { + const errors = { + remoteHost: e + } as FormErrors; + dispatch(stopSubmit(SITE_MANAGER_FORM_NAME, errors)); + } + } }), withStyles(styles)) @@ -107,11 +119,12 @@ export const SiteManagerPanelRoot = compose( - {sessions.map((session, index) => - + {sessions.map((session, index) => { + const validating = session.status === SessionStatus.BEING_VALIDATED; + return {session.clusterId} - {session.username} - {session.email} + {validating ? : session.username} + {validating ? : session.email}
- )} + ; + })} } @@ -138,7 +152,8 @@ export const SiteManagerPanelRoot = compose( component={TextField} placeholder="zzzz.arvadosapi.com" margin="normal" - label="New cluster"/> + label="New cluster" + autoFocus/>