fix: handle token expiry (#3847)

This commit is contained in:
Erez Rokah 2020-06-03 12:44:03 +03:00 committed by GitHub
parent 43ef28b5dc
commit 285c940562
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 282 additions and 17 deletions

View File

@ -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,

View File

@ -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<string>;
authenticator?: NetlifyAuthenticator;
auth?: unknown;
_mediaDisplayURLSem?: Semaphore;
squashMerges: boolean;
previewContext: string;
largeMediaURL: string;
_largeMediaClientPromise?: Promise<GitLfsClient>;
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,

View File

@ -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<unknown>;
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!();

View File

@ -202,11 +202,15 @@ export default class API {
user(): Promise<{ name: string; login: string }> {
if (!this._userPromise) {
this._userPromise = this.request('/user') as Promise<GitHubUser>;
this._userPromise = this.getUser();
}
return this._userPromise;
}
getUser() {
return this.request('/user') as Promise<GitHubUser>;
}
async hasWriteAccess() {
try {
const result: Octokit.ReposGetResponse = await this.request(this.repoURL);

View File

@ -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<string, unknown>) => (
<AuthenticationPage {...props} backend={this} />

View File

@ -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;
}

View File

@ -63,6 +63,10 @@ export default class ProxyBackend implements Implementation {
return false;
}
status() {
return Promise.resolve({ auth: true });
}
authComponent() {
return AuthenticationPage;
}

View File

@ -101,6 +101,10 @@ export default class TestBackend implements Implementation {
return false;
}
status() {
return Promise.resolve({ auth: true });
}
authComponent() {
return AuthenticationPage;
}

View File

@ -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());
});
};
}

View File

@ -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<State, {}, AnyAction>, 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));
}
};
}

View File

@ -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();

View File

@ -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));

View File

@ -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));

View File

@ -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;

View File

@ -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;

View File

@ -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<string> {

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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,
};

View File

@ -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: {