15803: Refactor internal user and API token management
authorPeter Amstutz <pamstutz@veritasgenetics.com>
Wed, 13 Nov 2019 21:40:26 +0000 (16:40 -0500)
committerPeter Amstutz <pamstutz@veritasgenetics.com>
Wed, 13 Nov 2019 21:40:26 +0000 (16:40 -0500)
Arvados-DCO-1.1-Signed-off-by: Peter Amstutz <pamstutz@veritasgenetics.com>

13 files changed:
src/index.tsx
src/models/user.ts
src/services/auth-service/auth-service.ts
src/services/services.ts
src/store/auth/auth-action-session.ts
src/store/auth/auth-action.ts
src/store/auth/auth-reducer.test.ts
src/store/auth/auth-reducer.ts
src/store/link-account-panel/link-account-panel-actions.ts
src/store/navigation/navigation-action.ts
src/store/store.ts
src/store/users/users-actions.ts
src/views-components/api-token/api-token.tsx

index d4203a87d85ad9982601e44e1faf45d0d0abcd3f..fcd8b91b3d1c889fbe644369105a8943c9072da1 100644 (file)
@@ -101,11 +101,19 @@ fetchConfig()
             },
             errorFn: (id, error) => {
                 console.error("Backend error:", error);
-                store.dispatch(snackbarActions.OPEN_SNACKBAR({
-                    message: `${error.errors[0]}`,
-                    kind: SnackbarKind.ERROR,
-                    hideDuration: 8000
-                }));
+                if (error.errors) {
+                    store.dispatch(snackbarActions.OPEN_SNACKBAR({
+                        message: `${error.errors[0]}`,
+                        kind: SnackbarKind.ERROR,
+                        hideDuration: 8000
+                    }));
+                } else {
+                    store.dispatch(snackbarActions.OPEN_SNACKBAR({
+                        message: `${error.message}`,
+                        kind: SnackbarKind.ERROR,
+                        hideDuration: 8000
+                    }));
+                }
             }
         });
         const store = configureStore(history, services);
index 2497864507787cef09d6d3914780b79136090d77..87a97dfcd1934b057369cee39bcfb82f389c05e0 100644 (file)
@@ -30,15 +30,8 @@ export const getUserFullname = (user?: User) => {
     return user ? `${user.firstName} ${user.lastName}` : "";
 };
 
-export interface UserResource extends Resource {
+export interface UserResource extends Resource, User {
     kind: ResourceKind.USER;
-    email: string;
-    username: string;
-    firstName: string;
-    lastName: string;
-    isAdmin: boolean;
-    prefs: UserPrefs;
     defaultOwnerUuid: string;
-    isActive: boolean;
     writableBy: string[];
 }
index d5cb4ec205c36cdfc9d0545a167218a454b3103a..2a939acdb54bd10dad588f8d5efbc39f24258c04 100644 (file)
@@ -151,7 +151,7 @@ export class AuthService {
                 throw e;
             });
     }
-
+    
     public getRootUuid() {
         const uuid = this.getOwnerUuid();
         const uuidParts = uuid ? uuid.split('-') : [];
index dd3178790a05bdca84c7456418d85562286f1a66..89b3d0ff0d3cad1d5c5b312c13e30288c82735ee 100644 (file)
@@ -35,8 +35,26 @@ import { LinkAccountService } from "./link-account-service/link-account-service"
 
 export type ServiceRepository = ReturnType<typeof createServices>;
 
+export function setAuthorizationHeader(services: ServiceRepository, token: string) {
+    services.apiClient.defaults.headers.common = {
+        Authorization: `Bearer ${token}`
+    };
+    services.webdavClient.defaults.headers = {
+        Authorization: `Bearer ${token}`
+    };
+}
+
+export function removeAuthorizationHeader(services: ServiceRepository) {
+    delete services.apiClient.defaults.headers.common;
+    delete services.webdavClient.defaults.headers.common;
+}
+
 export const createServices = (config: Config, actions: ApiActions) => {
-    const apiClient = Axios.create();
+    // Need to give empty 'headers' object or it will create an
+    // instance with a reference to the global default headers object,
+    // which is very bad because that means setAuthorizationHeader
+    // would update the global default instead of the instance default.
+    const apiClient = Axios.create({ headers: {} });
     apiClient.defaults.baseURL = config.baseUrl;
 
     const webdavClient = new WebDAV();
index 5b8acf9aeb061651b656590cd496c4a2d9e8bad8..c1b97adc3ea0faa1e9b685832150cfe59bf119b8 100644 (file)
@@ -5,7 +5,7 @@
 import { Dispatch } from "redux";
 import { setBreadcrumbs } from "~/store/breadcrumbs/breadcrumbs-actions";
 import { RootState } from "~/store/store";
-import { ServiceRepository } from "~/services/services";
+import { ServiceRepository, createServices, setAuthorizationHeader } from "~/services/services";
 import Axios from "axios";
 import { getUserFullname, User } from "~/models/user";
 import { authActions } from "~/store/auth/auth-action";
@@ -16,7 +16,7 @@ import {
 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 { AuthService } from "~/services/auth-service/auth-service";
 import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
 import * as jsSHA from "jssha";
 
@@ -81,15 +81,6 @@ const getRemoteHostConfig = async (remoteHost: string): Promise<Config | null> =
     return null;
 };
 
-const getUserDetails = async (baseUrl: string, token: string): Promise<UserDetailsResponse> => {
-    const resp = await Axios.get<UserDetailsResponse>(`${baseUrl}/users/current`, {
-        headers: {
-            Authorization: `OAuth2 ${token}`
-        }
-    });
-    return resp.data;
-};
-
 const invalidV2Token = "Must be a v2 token";
 
 export const getSaltedToken = (clusterId: string, token: string) => {
@@ -113,19 +104,13 @@ export const validateCluster = async (config: Config, useToken: string):
     Promise<{ user: User; token: string }> => {
 
     const saltedToken = getSaltedToken(config.uuidPrefix, useToken);
-    const user = await getUserDetails(config.baseUrl, saltedToken);
+
+    const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
+    setAuthorizationHeader(svc, saltedToken);
+
+    const user = await svc.authService.getUserDetails();
     return {
-        user: {
-            firstName: user.first_name,
-            lastName: user.last_name,
-            uuid: user.uuid,
-            ownerUuid: user.owner_uuid,
-            email: user.email,
-            isAdmin: user.is_admin,
-            isActive: user.is_active,
-            username: user.username,
-            prefs: user.prefs
-        },
+        user,
         token: saltedToken,
     };
 };
index cadf3c6e5e85593cde1069c1320333b0cf6ced70..e220acb2edd4c6eab3ae50b76dfea1bee2de6b7d 100644 (file)
@@ -4,21 +4,17 @@
 
 import { ofType, unionize, UnionOf } from '~/common/unionize';
 import { Dispatch } from "redux";
-import { AxiosInstance } from "axios";
 import { RootState } from "../store";
 import { ServiceRepository } from "~/services/services";
 import { SshKeyResource } from '~/models/ssh-key';
-import { User, UserResource } from "~/models/user";
+import { User } from "~/models/user";
 import { Session } from "~/models/session";
 import { Config } from '~/common/config';
-import { initSessions } from "~/store/auth/auth-action-session";
 import { cancelLinking } from '~/store/link-account-panel/link-account-panel-actions';
 import { matchTokenRoute, matchFedTokenRoute } from '~/routes/routes';
-import { AxiosError } from "axios";
+import { createServices, setAuthorizationHeader, removeAuthorizationHeader } from "~/services/services";
 
 export const authActions = unionize({
-    SAVE_API_TOKEN: ofType<string>(),
-    SAVE_USER: ofType<UserResource>(),
     LOGIN: {},
     LOGOUT: {},
     CONFIG: ofType<{ config: Config }>(),
@@ -36,19 +32,6 @@ export const authActions = unionize({
     REMOTE_CLUSTER_CONFIG: ofType<{ config: Config }>(),
 });
 
-export function setAuthorizationHeader(services: ServiceRepository, token: string) {
-    services.apiClient.defaults.headers.common = {
-        Authorization: `OAuth2 ${token}`
-    };
-    services.webdavClient.defaults.headers = {
-        Authorization: `OAuth2 ${token}`
-    };
-}
-
-function removeAuthorizationHeader(client: AxiosInstance) {
-    delete client.defaults.headers.common.Authorization;
-}
-
 export const initAuth = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
     // Cancel any link account ops in progress unless the user has
     // just logged in or there has been a successful link operation
@@ -64,48 +47,31 @@ export const initAuth = (config: Config) => (dispatch: Dispatch, getState: () =>
 };
 
 const init = (config: Config) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    const user = services.authService.getUser();
     const token = services.authService.getApiToken();
     let homeCluster = services.authService.getHomeCluster();
-    if (token) {
-        setAuthorizationHeader(services, token);
-    }
     if (homeCluster && !config.remoteHosts[homeCluster]) {
         homeCluster = undefined;
     }
     dispatch(authActions.CONFIG({ config }));
     dispatch(authActions.SET_HOME_CLUSTER(config.loginCluster || homeCluster || config.uuidPrefix));
-    document.title = `Arvados Workbench (${config.uuidPrefix})`;
-    if (token && user) {
-        dispatch(authActions.INIT({ user, token }));
-        dispatch<any>(initSessions(services.authService, config, user));
-        dispatch<any>(getUserDetails()).then((user: User) => {
-            dispatch(authActions.INIT({ user, token }));
-            if (!user.isActive) {
-                services.userService.activate(user.uuid).then((user: User) => {
-                    dispatch(authActions.INIT({ user, token }));
-                });
-            }
-        }).catch((err: AxiosError) => {
-            if (err.response) {
-                // Bad token
-                if (err.response.status === 401) {
-                    dispatch<any>(logout());
-                }
-            }
-        });
+
+    if (token && token !== "undefined") {
+        dispatch<any>(saveApiToken(token));
     }
 };
 
-export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    services.authService.saveApiToken(token);
-    setAuthorizationHeader(services, token);
-    dispatch(authActions.SAVE_API_TOKEN(token));
+export const getConfig = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Config => {
+    const state = getState().auth;
+    return state.remoteHostsConfig[state.localCluster];
 };
 
-export const saveUser = (user: UserResource) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    services.authService.saveUser(user);
-    dispatch(authActions.SAVE_USER(user));
+export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
+    const config = dispatch<any>(getConfig);
+    const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
+    setAuthorizationHeader(svc, token);
+    return svc.authService.getUserDetails().then((user: User) => {
+        dispatch(authActions.INIT({ user, token }));
+    });
 };
 
 export const login = (uuidPrefix: string, homeCluster: string, loginCluster: string,
@@ -120,18 +86,9 @@ export const logout = (deleteLinkData: boolean = false) => (dispatch: Dispatch,
     }
     services.authService.removeApiToken();
     services.authService.removeUser();
-    removeAuthorizationHeader(services.apiClient);
+    removeAuthorizationHeader(services);
     services.authService.logout();
     dispatch(authActions.LOGOUT());
 };
 
-export const getUserDetails = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<User> => {
-    dispatch(authActions.USER_DETAILS_REQUEST());
-    return services.authService.getUserDetails().then(user => {
-        services.authService.saveUser(user);
-        dispatch(authActions.USER_DETAILS_SUCCESS(user));
-        return user;
-    });
-};
-
 export type AuthAction = UnionOf<typeof authActions>;
index 8311e861ed67e647879a5f2ebf1b09b98a305b13..30bee3bc1b7e9d49abcbf787c7a6c98d3bf754bf 100644 (file)
@@ -49,23 +49,6 @@ describe('auth-reducer', () => {
         });
     });
 
-    it('should save api token', () => {
-        const initialState = undefined;
-
-        const state = reducer(initialState, authActions.SAVE_API_TOKEN("token"));
-        expect(state).toEqual({
-            apiToken: "token",
-            user: undefined,
-            sshKeys: [],
-            sessions: [],
-            homeCluster: "",
-            localCluster: "",
-            loginCluster: "",
-            remoteHosts: {},
-            remoteHostsConfig: {}
-        });
-    });
-
     it('should set user details on success fetch', () => {
         const initialState = undefined;
 
index e932b97dd1920f3f6f75a2fd0fb81707e1208956..0bc85591eb59b2f60ee3c3b6e7bfd3624552e346 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { authActions, AuthAction } from "./auth-action";
-import { User, UserResource } from "~/models/user";
+import { User } from "~/models/user";
 import { ServiceRepository } from "~/services/services";
 import { SshKeyResource } from '~/models/ssh-key';
 import { Session } from "~/models/session";
@@ -35,12 +35,6 @@ const initialState: AuthState = {
 
 export const authReducer = (services: ServiceRepository) => (state = initialState, action: AuthAction) => {
     return authActions.match(action, {
-        SAVE_API_TOKEN: (token: string) => {
-            return { ...state, apiToken: token };
-        },
-        SAVE_USER: (user: UserResource) => {
-            return { ...state, user };
-        },
         CONFIG: ({ config }) => {
             return {
                 ...state,
index 88d2a4ec2911ed10ab7876de7b823acb2964bcd0..c47427f6144f652507fd65e5a32c627c09dd2a41 100644 (file)
@@ -4,16 +4,16 @@
 
 import { Dispatch } from "redux";
 import { RootState } from "~/store/store";
-import { ServiceRepository } from "~/services/services";
+import { ServiceRepository, createServices, setAuthorizationHeader } from "~/services/services";
 import { setBreadcrumbs } from "~/store/breadcrumbs/breadcrumbs-actions";
 import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions";
 import { LinkAccountType, AccountToLink, LinkAccountStatus } from "~/models/link-account";
-import { saveApiToken, saveUser } from "~/store/auth/auth-action";
+import { authActions, getConfig } from "~/store/auth/auth-action";
 import { unionize, ofType, UnionOf } from '~/common/unionize';
 import { UserResource } from "~/models/user";
 import { GroupResource } from "~/models/group";
 import { LinkAccountPanelError, OriginatingUser } from "./link-account-panel-reducer";
-import { login, logout, setAuthorizationHeader } from "~/store/auth/auth-action";
+import { login, logout } from "~/store/auth/auth-action";
 import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
 import { WORKBENCH_LOADING_SCREEN } from '~/store/workbench/workbench-actions';
 
@@ -83,8 +83,7 @@ export const checkForLinkStatus = () =>
 
 export const switchUser = (user: UserResource, token: string) =>
     (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(saveUser(user));
-        dispatch(saveApiToken(token));
+        dispatch(authActions.INIT({ user, token }));
     };
 
 export const linkFailed = () =>
@@ -138,9 +137,10 @@ export const loadLinkAccountPanel = () =>
 
                     // Use the token of the user we are getting data for. This avoids any admin/non-admin permissions
                     // issues since a user will always be able to query the api server for their own user data.
-                    setAuthorizationHeader(services, linkAccountData.token);
-                    const savedUserResource = await services.userService.get(linkAccountData.userUuid);
-                    setAuthorizationHeader(services, curToken);
+                    const config = dispatch<any>(getConfig);
+                    const svc = createServices(config, { progressFn: () => { }, errorFn: () => { } });
+                    setAuthorizationHeader(svc, linkAccountData.token);
+                    const savedUserResource = await svc.userService.get(linkAccountData.userUuid);
 
                     let params: any;
                     if (linkAccountData.type === LinkAccountType.ACCESS_OTHER_ACCOUNT || linkAccountData.type === LinkAccountType.ACCESS_OTHER_REMOTE_ACCOUNT) {
index f60f37f93420158ea7d13e4a9f8dfe88e0149c9a..5ece1abaa1488cb989a2a9e306624dfa8c693206 100644 (file)
@@ -59,9 +59,9 @@ export const pushOrGoto = (url: string): AnyAction => {
 export const navigateToProcessLogs = compose(push, getProcessLogUrl);
 
 export const navigateToRootProject = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    const rootProjectUuid = services.authService.getUuid();
-    if (rootProjectUuid) {
-        dispatch<any>(navigateTo(rootProjectUuid));
+    const usr = getState().auth.user;
+    if (usr) {
+        dispatch<any>(navigateTo(usr.uuid));
     }
 };
 
index 8a2ca2400cb1cbd3f1229cce96294291e91c8d66..f2eeefaa3f697090ffdcfb2fba9221554bbd5ecc 100644 (file)
@@ -8,6 +8,7 @@ import thunkMiddleware from 'redux-thunk';
 import { History } from "history";
 
 import { authReducer } from "./auth/auth-reducer";
+import { authMiddleware } from "./auth/auth-middleware";
 import { configReducer } from "./config/config-reducer";
 import { dataExplorerReducer } from './data-explorer/data-explorer-reducer';
 import { detailsPanelReducer } from './details-panel/details-panel-reducer';
@@ -126,6 +127,7 @@ export function configureStore(history: History, services: ServiceRepository): R
     const middlewares: Middleware[] = [
         routerMiddleware(history),
         thunkMiddleware.withExtraArgument(services),
+        authMiddleware(services),
         projectPanelMiddleware,
         favoritePanelMiddleware,
         trashPanelMiddleware,
index 9e9f09543fd1e253d14a6e623932de0756d496bd..fc27b56988984ec86a09762047684349f12b0798 100644 (file)
@@ -12,7 +12,7 @@ import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions
 import { UserResource } from "~/models/user";
 import { getResource } from '~/store/resources/resources';
 import { navigateTo, navigateToUsers, navigateToRootProject } from "~/store/navigation/navigation-action";
-import { saveApiToken } from '~/store/auth/auth-action';
+import { authActions } from '~/store/auth/auth-action';
 
 export const USERS_PANEL_ID = 'usersPanel';
 export const USER_ATTRIBUTES_DIALOG = 'userAttributesDialog';
@@ -53,13 +53,12 @@ export const loginAs = (uuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const { resources } = getState();
         const data = getResource<UserResource>(uuid)(resources);
-        if (data) {
-            services.authService.saveUser(data);
-        }
         const client = await services.apiClientAuthorizationService.create({ ownerUuid: uuid });
-        dispatch<any>(saveApiToken(`v2/${client.uuid}/${client.apiToken}`));
-        location.reload();
-        dispatch<any>(navigateToRootProject);
+        if (data) {
+            dispatch<any>(authActions.INIT({ user: data, token: `v2/${client.uuid}/${client.apiToken}` }));
+            location.reload();
+            dispatch<any>(navigateToRootProject);
+        }          
     };
 
 export const openUserCreateDialog = () =>
index 0e90b720c378670e4c7291c8db04c9f59689f094..e11afa7bf3395b587e23c312bf331b69151c8a97 100644 (file)
@@ -5,13 +5,11 @@
 import { RouteProps } from "react-router";
 import * as React from "react";
 import { connect, DispatchProp } from "react-redux";
-import { getUserDetails, saveApiToken } from "~/store/auth/auth-action";
+import { saveApiToken } from "~/store/auth/auth-action";
 import { getUrlParameter } from "~/common/url";
 import { AuthService } from "~/services/auth-service/auth-service";
 import { navigateToRootProject, navigateToLinkAccount } from "~/store/navigation/navigation-action";
-import { User } from "~/models/user";
 import { Config } from "~/common/config";
-import { initSessions } from "~/store/auth/auth-action-session";
 import { getAccountLinkData } from "~/store/link-account-panel/link-account-panel-actions";
 
 interface ApiTokenProps {
@@ -26,10 +24,7 @@ export const ApiToken = connect()(
             const search = this.props.location ? this.props.location.search : "";
             const apiToken = getUrlParameter(search, 'api_token');
             const loadMainApp = this.props.loadMainApp;
-            this.props.dispatch(saveApiToken(apiToken));
-            this.props.dispatch<any>(getUserDetails()).then((user: User) => {
-                this.props.dispatch(initSessions(this.props.authService, this.props.config, user));
-            }).finally(() => {
+            this.props.dispatch<any>(saveApiToken(apiToken)).finally(() => {
                 if (loadMainApp) {
                     if (this.props.dispatch(getAccountLinkData())) {
                         this.props.dispatch(navigateToLinkAccount);