From 285c940562548d7bc88de244123ba87ff66fba65 Mon Sep 17 00:00:00 2001 From: Erez Rokah Date: Wed, 3 Jun 2020 12:44:03 +0300 Subject: [PATCH] fix: handle token expiry (#3847) --- .../src/AuthenticationPage.js | 13 ++-- .../src/implementation.ts | 22 ++++++- .../src/implementation.ts | 42 +++++++++++- .../netlify-cms-backend-github/src/API.ts | 6 +- .../src/implementation.tsx | 13 ++++ .../src/implementation.ts | 13 ++++ .../src/implementation.ts | 4 ++ .../src/implementation.ts | 4 ++ packages/netlify-cms-core/src/actions/auth.js | 3 +- .../netlify-cms-core/src/actions/status.ts | 66 +++++++++++++++++++ packages/netlify-cms-core/src/backend.ts | 25 ++++++- .../src/components/App/Header.js | 21 +++++- .../netlify-cms-core/src/reducers/globalUI.js | 7 +- .../netlify-cms-core/src/reducers/index.ts | 2 + .../netlify-cms-core/src/reducers/status.ts | 33 ++++++++++ packages/netlify-cms-core/src/types/redux.ts | 7 ++ .../src/AccessTokenError.ts | 11 ++++ .../src/implementation.ts | 3 + packages/netlify-cms-lib-util/src/index.ts | 3 + packages/netlify-cms-locales/src/en/index.js | 1 + 20 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 packages/netlify-cms-core/src/actions/status.ts create mode 100644 packages/netlify-cms-core/src/reducers/status.ts create mode 100644 packages/netlify-cms-lib-util/src/AccessTokenError.ts diff --git a/packages/netlify-cms-backend-bitbucket/src/AuthenticationPage.js b/packages/netlify-cms-backend-bitbucket/src/AuthenticationPage.js index 6ece263d..9a459153 100644 --- a/packages/netlify-cms-backend-bitbucket/src/AuthenticationPage.js +++ b/packages/netlify-cms-backend-bitbucket/src/AuthenticationPage.js @@ -23,14 +23,15 @@ export default class BitbucketAuthenticationPage extends React.Component { state = {}; componentDidMount() { - const { - auth_type: authType = '', - base_url = 'https://bitbucket.org', - auth_endpoint = 'site/oauth2/authorize', - app_id = '', - } = this.props.config.backend; + const { auth_type: authType = '' } = this.props.config.backend; if (authType === 'implicit') { + const { + base_url = 'https://bitbucket.org', + auth_endpoint = 'site/oauth2/authorize', + app_id = '', + } = this.props.config.backend; + this.auth = new ImplicitAuthenticator({ base_url, auth_endpoint, diff --git a/packages/netlify-cms-backend-bitbucket/src/implementation.ts b/packages/netlify-cms-backend-bitbucket/src/implementation.ts index db40a296..4cbf4b5d 100644 --- a/packages/netlify-cms-backend-bitbucket/src/implementation.ts +++ b/packages/netlify-cms-backend-bitbucket/src/implementation.ts @@ -37,6 +37,7 @@ import { generateContentKey, localForage, allEntriesByFolder, + AccessTokenError, } from 'netlify-cms-lib-util'; import { NetlifyAuthenticator } from 'netlify-cms-lib-auth'; import AuthenticationPage from './AuthenticationPage'; @@ -66,12 +67,12 @@ export default class BitbucketBackend implements Implementation { refreshToken?: string; refreshedTokenPromise?: Promise; authenticator?: NetlifyAuthenticator; - auth?: unknown; _mediaDisplayURLSem?: Semaphore; squashMerges: boolean; previewContext: string; largeMediaURL: string; _largeMediaClientPromise?: Promise; + authType: string; constructor(config: Config, options = {}) { this.options = { @@ -105,12 +106,26 @@ export default class BitbucketBackend implements Implementation { this.squashMerges = config.backend.squash_merges || false; this.previewContext = config.backend.preview_context || ''; this.lock = asyncLock(); + this.authType = config.backend.auth_type || ''; } isGitBackend() { return true; } + async status() { + const auth = + (await this.api + ?.user() + .then(user => !!user) + .catch(e => { + console.warn('Failed getting Bitbucket user', e); + return false; + })) || false; + + return { auth }; + } + authComponent() { return AuthenticationPage; } @@ -180,12 +195,15 @@ export default class BitbucketBackend implements Implementation { } getRefreshedAccessToken() { + if (this.authType === 'implicit') { + throw new AccessTokenError(`Can't refresh access token when using implicit auth`); + } if (this.refreshedTokenPromise) { return this.refreshedTokenPromise; } // instantiating a new Authenticator on each refresh isn't ideal, - if (!this.auth) { + if (!this.authenticator) { const cfg = { // eslint-disable-next-line @typescript-eslint/camelcase base_url: this.baseUrl, diff --git a/packages/netlify-cms-backend-git-gateway/src/implementation.ts b/packages/netlify-cms-backend-git-gateway/src/implementation.ts index 2efac342..c7be0c7d 100644 --- a/packages/netlify-cms-backend-git-gateway/src/implementation.ts +++ b/packages/netlify-cms-backend-git-gateway/src/implementation.ts @@ -24,6 +24,7 @@ import { getPointerFileForMediaFileObj, getLargeMediaFilteredMediaFiles, DisplayURLObject, + AccessTokenError, } from 'netlify-cms-lib-util'; import { GitHubBackend } from 'netlify-cms-backend-github'; import { GitLabBackend } from 'netlify-cms-backend-gitlab'; @@ -38,12 +39,14 @@ type NetlifyIdentity = { currentUser: () => User; on: (event: string, args: unknown) => void; init: () => void; + store: { user: unknown; modal: { page: string }; saving: boolean }; }; type AuthClient = { logout: () => void; currentUser: () => unknown; login?(email: string, password: string, remember?: boolean): Promise; + clearStore: () => void; }; declare global { @@ -168,6 +171,18 @@ export default class GitGateway implements Implementation { return true; } + async status() { + const auth = + (await this.tokenPromise?.() + .then(token => !!token) + .catch(e => { + console.warn('Failed getting Identity token', e); + return false; + })) || false; + + return { auth }; + } + async getAuthClient() { if (this.authClient) { return this.authClient; @@ -177,6 +192,14 @@ export default class GitGateway implements Implementation { this.authClient = { logout: () => window.netlifyIdentity?.logout(), currentUser: () => window.netlifyIdentity?.currentUser(), + clearStore: () => { + const store = window.netlifyIdentity?.store; + if (store) { + store.user = null; + store.modal.page = 'login'; + store.saving = false; + } + }, }; } else { const goTrue = new GoTrue({ APIUrl: this.apiUrl }); @@ -189,6 +212,7 @@ export default class GitGateway implements Implementation { }, currentUser: () => goTrue.currentUser(), login: goTrue.login.bind(goTrue), + clearStore: () => undefined, }; } return this.authClient; @@ -203,7 +227,15 @@ export default class GitGateway implements Implementation { authenticate(credentials: Credentials) { const user = credentials as NetlifyUser; - this.tokenPromise = user.jwt.bind(user); + this.tokenPromise = async () => { + try { + const func = user.jwt.bind(user); + const token = await func(); + return token; + } catch (error) { + throw new AccessTokenError(`Failed getting access token: ${error.message}`); + } + }; return this.tokenPromise!().then(async token => { if (!this.backendType) { const { @@ -303,7 +335,13 @@ export default class GitGateway implements Implementation { async logout() { const client = await this.getAuthClient(); - return client.logout(); + try { + client.logout(); + } catch (e) { + // due to a bug in the identity widget (gotrue-js actually) the store is not reset if logout fails + // TODO: remove after https://github.com/netlify/gotrue-js/pull/83 is merged + client.clearStore(); + } } getToken() { return this.tokenPromise!(); diff --git a/packages/netlify-cms-backend-github/src/API.ts b/packages/netlify-cms-backend-github/src/API.ts index 7b0bc71b..73e1acc1 100644 --- a/packages/netlify-cms-backend-github/src/API.ts +++ b/packages/netlify-cms-backend-github/src/API.ts @@ -202,11 +202,15 @@ export default class API { user(): Promise<{ name: string; login: string }> { if (!this._userPromise) { - this._userPromise = this.request('/user') as Promise; + this._userPromise = this.getUser(); } return this._userPromise; } + getUser() { + return this.request('/user') as Promise; + } + async hasWriteAccess() { try { const result: Octokit.ReposGetResponse = await this.request(this.repoURL); diff --git a/packages/netlify-cms-backend-github/src/implementation.tsx b/packages/netlify-cms-backend-github/src/implementation.tsx index d7dfce69..a2d4d55b 100644 --- a/packages/netlify-cms-backend-github/src/implementation.tsx +++ b/packages/netlify-cms-backend-github/src/implementation.tsx @@ -111,6 +111,19 @@ export default class GitHub implements Implementation { return true; } + async status() { + const auth = + (await this.api + ?.getUser() + .then(user => !!user) + .catch(e => { + console.warn('Failed getting GitHub user', e); + return false; + })) || false; + + return { auth }; + } + authComponent() { const wrappedAuthenticationPage = (props: Record) => ( diff --git a/packages/netlify-cms-backend-gitlab/src/implementation.ts b/packages/netlify-cms-backend-gitlab/src/implementation.ts index 420d5e84..16fe56fe 100644 --- a/packages/netlify-cms-backend-gitlab/src/implementation.ts +++ b/packages/netlify-cms-backend-gitlab/src/implementation.ts @@ -87,6 +87,19 @@ export default class GitLab implements Implementation { return true; } + async status() { + const auth = + (await this.api + ?.user() + .then(user => !!user) + .catch(e => { + console.warn('Failed getting GitLab user', e); + return false; + })) || false; + + return { auth }; + } + authComponent() { return AuthenticationPage; } diff --git a/packages/netlify-cms-backend-proxy/src/implementation.ts b/packages/netlify-cms-backend-proxy/src/implementation.ts index 125fdfd3..1eba44fd 100644 --- a/packages/netlify-cms-backend-proxy/src/implementation.ts +++ b/packages/netlify-cms-backend-proxy/src/implementation.ts @@ -63,6 +63,10 @@ export default class ProxyBackend implements Implementation { return false; } + status() { + return Promise.resolve({ auth: true }); + } + authComponent() { return AuthenticationPage; } diff --git a/packages/netlify-cms-backend-test/src/implementation.ts b/packages/netlify-cms-backend-test/src/implementation.ts index 77534cce..de7e2535 100644 --- a/packages/netlify-cms-backend-test/src/implementation.ts +++ b/packages/netlify-cms-backend-test/src/implementation.ts @@ -101,6 +101,10 @@ export default class TestBackend implements Implementation { return false; } + status() { + return Promise.resolve({ auth: true }); + } + authComponent() { return AuthenticationPage; } diff --git a/packages/netlify-cms-core/src/actions/auth.js b/packages/netlify-cms-core/src/actions/auth.js index 410c7694..b6c1e201 100644 --- a/packages/netlify-cms-core/src/actions/auth.js +++ b/packages/netlify-cms-core/src/actions/auth.js @@ -1,7 +1,7 @@ import { actions as notifActions } from 'redux-notifications'; import { currentBackend } from 'coreSrc/backend'; -const { notifSend } = notifActions; +const { notifSend, notifClear } = notifActions; export const AUTH_REQUEST = 'AUTH_REQUEST'; export const AUTH_SUCCESS = 'AUTH_SUCCESS'; @@ -111,6 +111,7 @@ export function logoutUser() { const backend = currentBackend(state.config); Promise.resolve(backend.logout()).then(() => { dispatch(logout()); + dispatch(notifClear()); }); }; } diff --git a/packages/netlify-cms-core/src/actions/status.ts b/packages/netlify-cms-core/src/actions/status.ts new file mode 100644 index 00000000..11a2f1ed --- /dev/null +++ b/packages/netlify-cms-core/src/actions/status.ts @@ -0,0 +1,66 @@ +import { State } from '../types/redux'; +import { currentBackend } from '../backend'; +import { ThunkDispatch } from 'redux-thunk'; +import { AnyAction } from 'redux'; +import { actions as notifActions } from 'redux-notifications'; + +const { notifSend } = notifActions; + +export const STATUS_REQUEST = 'STATUS_REQUEST'; +export const STATUS_SUCCESS = 'STATUS_SUCCESS'; +export const STATUS_FAILURE = 'STATUS_FAILURE'; + +export function statusRequest() { + return { + type: STATUS_REQUEST, + }; +} + +export function statusSuccess(status: { auth: boolean }) { + return { + type: STATUS_SUCCESS, + payload: { status }, + }; +} + +export function statusFailure(error: Error) { + return { + type: STATUS_FAILURE, + error, + }; +} + +export function checkBackendStatus() { + return async (dispatch: ThunkDispatch, getState: () => State) => { + try { + const state = getState(); + if (state.status.get('isFetching') === true) { + return; + } + + dispatch(statusRequest()); + const backend = currentBackend(state.config); + const status = await backend.status(); + + const authError = status.auth === false; + if (authError) { + const key = 'ui.toast.onLoggedOut'; + const existingNotification = state.notifs.find(n => n.message?.key === key); + if (!existingNotification) { + dispatch( + notifSend({ + message: { + key: 'ui.toast.onLoggedOut', + }, + kind: 'danger', + }), + ); + } + } + + dispatch(statusSuccess(status)); + } catch (error) { + dispatch(statusFailure(error)); + } + }; +} diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index 7327dea1..f2b895d4 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -179,6 +179,21 @@ export class Backend { } } + async status() { + const attempts = 3; + let status: { auth: boolean } = { auth: false }; + for (let i = 1; i <= attempts; i++) { + status = await this.implementation!.status(); + // return on first success + if (Object.values(status).every(s => s === true)) { + return status; + } else { + await new Promise(resolve => setTimeout(resolve, i * 1000)); + } + } + return status; + } + currentUser() { if (this.user) { return this.user; @@ -222,13 +237,17 @@ export class Backend { }); } - logout() { - return Promise.resolve(this.implementation.logout()).then(() => { + async logout() { + try { + await this.implementation.logout(); + } catch (e) { + console.warn('Error during logout', e.message); + } finally { this.user = null; if (this.authStore) { this.authStore.logout(); } - }); + } } getToken = () => this.implementation.getToken(); diff --git a/packages/netlify-cms-core/src/components/App/Header.js b/packages/netlify-cms-core/src/components/App/Header.js index bcc7a19b..2f7567b9 100644 --- a/packages/netlify-cms-core/src/components/App/Header.js +++ b/packages/netlify-cms-core/src/components/App/Header.js @@ -17,6 +17,8 @@ import { zIndex, } from 'netlify-cms-ui-default'; import SettingsDropdown from 'UI/SettingsDropdown'; +import { connect } from 'react-redux'; +import { checkBackendStatus } from '../../actions/status'; const styles = { buttonActive: css` @@ -120,8 +122,21 @@ class Header extends React.Component { displayUrl: PropTypes.string, isTestRepo: PropTypes.bool, t: PropTypes.func.isRequired, + checkBackendStatus: PropTypes.func.isRequired, }; + intervalId; + + componentDidMount() { + this.intervalId = setInterval(() => { + this.props.checkBackendStatus(); + }, 5 * 60 * 1000); + } + + componentWillUnmount() { + clearInterval(this.intervalId); + } + handleCreatePostClick = collectionName => { const { onCreateEntryClick } = this.props; if (onCreateEntryClick) { @@ -211,4 +226,8 @@ class Header extends React.Component { } } -export default translate()(Header); +const mapDispatchToProps = { + checkBackendStatus, +}; + +export default connect(null, mapDispatchToProps)(translate()(Header)); diff --git a/packages/netlify-cms-core/src/reducers/globalUI.js b/packages/netlify-cms-core/src/reducers/globalUI.js index d8152a73..04d06af2 100644 --- a/packages/netlify-cms-core/src/reducers/globalUI.js +++ b/packages/netlify-cms-core/src/reducers/globalUI.js @@ -1,7 +1,12 @@ import { Map } from 'immutable'; import { USE_OPEN_AUTHORING } from 'Actions/auth'; -const LOADING_IGNORE_LIST = ['DEPLOY_PREVIEW']; +const LOADING_IGNORE_LIST = [ + 'DEPLOY_PREVIEW', + 'STATUS_REQUEST', + 'STATUS_SUCCESS', + 'STATUS_FAILURE', +]; const ignoreWhenLoading = action => LOADING_IGNORE_LIST.some(type => action.type.includes(type)); diff --git a/packages/netlify-cms-core/src/reducers/index.ts b/packages/netlify-cms-core/src/reducers/index.ts index 893f2fa5..af5fdd25 100644 --- a/packages/netlify-cms-core/src/reducers/index.ts +++ b/packages/netlify-cms-core/src/reducers/index.ts @@ -11,6 +11,7 @@ import medias from './medias'; import mediaLibrary from './mediaLibrary'; import deploys, * as fromDeploys from './deploys'; import globalUI from './globalUI'; +import status from './status'; import { Status } from '../constants/publishModes'; import { State, Collection } from '../types/redux'; @@ -28,6 +29,7 @@ const reducers = { mediaLibrary, deploys, globalUI, + status, }; export default reducers; diff --git a/packages/netlify-cms-core/src/reducers/status.ts b/packages/netlify-cms-core/src/reducers/status.ts new file mode 100644 index 00000000..336499bd --- /dev/null +++ b/packages/netlify-cms-core/src/reducers/status.ts @@ -0,0 +1,33 @@ +import { Map, fromJS } from 'immutable'; +import { AnyAction } from 'redux'; +import { STATUS_REQUEST, STATUS_SUCCESS, STATUS_FAILURE } from '../actions/status'; +import { Status } from '../types/redux'; + +export interface EntriesAction extends AnyAction { + payload: { status: { auth: boolean }; error?: Error }; +} + +const status = (state = Map(), action: EntriesAction) => { + switch (action.type) { + case STATUS_REQUEST: + return state.set('isFetching', true); + case STATUS_SUCCESS: + return state.withMutations(map => { + map.set('isFetching', false); + map.set('status', fromJS(action.payload.status)); + }); + case STATUS_FAILURE: + return state.withMutations(map => { + map.set('isFetching', false); + map.set('error', action.payload.error); + }); + default: + return state; + } +}; + +export const selectStatus = (status: Status) => { + return status.get('status')?.toJS() || {}; +}; + +export default status; diff --git a/packages/netlify-cms-core/src/types/redux.ts b/packages/netlify-cms-core/src/types/redux.ts index 242b82e2..e466dfa8 100644 --- a/packages/netlify-cms-core/src/types/redux.ts +++ b/packages/netlify-cms-core/src/types/redux.ts @@ -241,6 +241,11 @@ export type Search = StaticallyTypedRecord<{ export type Cursors = StaticallyTypedRecord<{}>; +export type Status = StaticallyTypedRecord<{ + isFetching: boolean; + status: StaticallyTypedRecord<{ auth: boolean }>; +}>; + export interface State { config: Config; cursors: Cursors; @@ -253,6 +258,8 @@ export interface State { medias: Medias; mediaLibrary: MediaLibrary; search: Search; + notifs: { message: { key: string }; kind: string; id: number }[]; + status: Status; } export interface MediasAction extends Action { diff --git a/packages/netlify-cms-lib-util/src/AccessTokenError.ts b/packages/netlify-cms-lib-util/src/AccessTokenError.ts new file mode 100644 index 00000000..e446dc72 --- /dev/null +++ b/packages/netlify-cms-lib-util/src/AccessTokenError.ts @@ -0,0 +1,11 @@ +export const ACCESS_TOKEN_ERROR = 'ACCESS_TOKEN_ERROR'; + +export default class AccessTokenError extends Error { + message: string; + + constructor(message: string) { + super(message); + this.message = message; + this.name = ACCESS_TOKEN_ERROR; + } +} diff --git a/packages/netlify-cms-lib-util/src/implementation.ts b/packages/netlify-cms-lib-util/src/implementation.ts index c272440e..1fb48d83 100644 --- a/packages/netlify-cms-lib-util/src/implementation.ts +++ b/packages/netlify-cms-lib-util/src/implementation.ts @@ -84,6 +84,8 @@ export type Config = { large_media_url?: string; use_large_media_transforms_in_media_library?: boolean; proxy_url?: string; + auth_type?: string; + app_id?: string; }; media_folder: string; base_url?: string; @@ -139,6 +141,7 @@ export interface Implementation { ) => Promise<{ entries: ImplementationEntry[]; cursor: Cursor }>; isGitBackend?: () => boolean; + status: () => Promise<{ auth: boolean }>; } const MAX_CONCURRENT_DOWNLOADS = 10; diff --git a/packages/netlify-cms-lib-util/src/index.ts b/packages/netlify-cms-lib-util/src/index.ts index 62ae191d..bcb1adc9 100644 --- a/packages/netlify-cms-lib-util/src/index.ts +++ b/packages/netlify-cms-lib-util/src/index.ts @@ -1,6 +1,7 @@ import APIError from './APIError'; import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from './Cursor'; import EditorialWorkflowError, { EDITORIAL_WORKFLOW_ERROR } from './EditorialWorkflowError'; +import AccessTokenError from './AccessTokenError'; import localForage from './localForage'; import { isAbsolutePath, basename, fileExtensionWithSeparator, fileExtension } from './path'; import { onlySuccessfulPromises, flowAsync, then } from './promise'; @@ -138,6 +139,7 @@ export const NetlifyCmsLibUtil = { blobToFileObj, requestWithBackoff, allEntriesByFolder, + AccessTokenError, }; export { APIError, @@ -192,4 +194,5 @@ export { blobToFileObj, requestWithBackoff, allEntriesByFolder, + AccessTokenError, }; diff --git a/packages/netlify-cms-locales/src/en/index.js b/packages/netlify-cms-locales/src/en/index.js index 4999643e..4eab2bd4 100644 --- a/packages/netlify-cms-locales/src/en/index.js +++ b/packages/netlify-cms-locales/src/en/index.js @@ -223,6 +223,7 @@ const en = { entryUpdated: 'Entry status updated', onDeleteUnpublishedChanges: 'Unpublished changes deleted', onFailToAuth: '%{details}', + onLoggedOut: 'You have been logged out, please back up any data and login again', }, }, workflow: {