Handle session expiration

This commit is contained in:
dominik-zeglen 2020-05-07 13:04:15 +02:00
parent 3f8c5b0c17
commit a5e72c456e
11 changed files with 130 additions and 9 deletions

View file

@ -7,11 +7,16 @@ import {
} from "@saleor/utils/credentialsManagement"; } from "@saleor/utils/credentialsManagement";
import { MutationFunction, MutationResult } from "react-apollo"; import { MutationFunction, MutationResult } from "react-apollo";
import { maybe } from "@saleor/misc"; import { maybe } from "@saleor/misc";
import { TypedTokenAuthMutation, TypedVerifyTokenMutation } from "./mutations"; import {
TypedTokenAuthMutation,
TypedVerifyTokenMutation,
TokenRefreshMutation
} from "./mutations";
import { TokenAuth, TokenAuthVariables } from "./types/TokenAuth"; import { TokenAuth, TokenAuthVariables } from "./types/TokenAuth";
import { User } from "./types/User"; import { User } from "./types/User";
import { VerifyToken, VerifyTokenVariables } from "./types/VerifyToken"; import { VerifyToken, VerifyTokenVariables } from "./types/VerifyToken";
import { getAuthToken, removeAuthToken, setAuthToken } from "./utils"; import { getAuthToken, removeAuthToken, setAuthToken } from "./utils";
import { RefreshToken, RefreshTokenVariables } from "./types/RefreshToken";
import { UserContext } from "./"; import { UserContext } from "./";
interface AuthProviderOperationsProps { interface AuthProviderOperationsProps {
@ -30,9 +35,17 @@ const AuthProviderOperations: React.FC<AuthProviderOperationsProps> = ({
{(...tokenAuth) => ( {(...tokenAuth) => (
<TypedVerifyTokenMutation> <TypedVerifyTokenMutation>
{(...tokenVerify) => ( {(...tokenVerify) => (
<AuthProvider tokenAuth={tokenAuth} tokenVerify={tokenVerify}> <TokenRefreshMutation>
{children} {(...tokenRefresh) => (
</AuthProvider> <AuthProvider
tokenAuth={tokenAuth}
tokenVerify={tokenVerify}
tokenRefresh={tokenRefresh}
>
{children}
</AuthProvider>
)}
</TokenRefreshMutation>
)} )}
</TypedVerifyTokenMutation> </TypedVerifyTokenMutation>
)} )}
@ -55,6 +68,10 @@ interface AuthProviderProps {
MutationFunction<VerifyToken, VerifyTokenVariables>, MutationFunction<VerifyToken, VerifyTokenVariables>,
MutationResult<VerifyToken> MutationResult<VerifyToken>
]; ];
tokenRefresh: [
MutationFunction<RefreshToken, RefreshTokenVariables>,
MutationResult<RefreshToken>
];
} }
interface AuthProviderState { interface AuthProviderState {
@ -143,6 +160,16 @@ class AuthProvider extends React.Component<
return tokenVerifyFn({ variables: { token } }); return tokenVerifyFn({ variables: { token } });
}; };
refreshToken = async () => {
const { tokenRefresh } = this.props;
const [tokenRefreshFn] = tokenRefresh;
const token = getAuthToken();
const refreshData = await tokenRefreshFn({ variables: { token } });
setAuthToken(refreshData.data.tokenRefresh.token, this.state.persistToken);
};
render() { render() {
const { children, tokenAuth, tokenVerify } = this.props; const { children, tokenAuth, tokenVerify } = this.props;
const tokenAuthOpts = tokenAuth[1]; const tokenAuthOpts = tokenAuth[1];
@ -157,6 +184,7 @@ class AuthProvider extends React.Component<
loginByToken: this.loginByToken, loginByToken: this.loginByToken,
logout: this.logout, logout: this.logout,
tokenAuthLoading: tokenAuthOpts.loading, tokenAuthLoading: tokenAuthOpts.loading,
tokenRefresh: this.refreshToken,
tokenVerifyLoading: tokenVerifyOpts.loading, tokenVerifyLoading: tokenVerifyOpts.loading,
user user
}} }}

15
src/auth/errors.ts Normal file
View file

@ -0,0 +1,15 @@
import { GraphQLError } from "graphql";
import { findValueInEnum } from "@saleor/misc";
export enum JWTError {
invalid = "JSONWebTokenError",
expired = "JSONWebTokenExpired"
}
export function isJwtError(error: GraphQLError): boolean {
return !!findValueInEnum(error.extensions.exception.code, JWTError);
}
export function isTokenExpired(error: GraphQLError): boolean {
return error.extensions.exception.code === JWTError.expired;
}

View file

@ -19,6 +19,7 @@ interface UserContext {
loginByToken: (token: string, user: User) => void; loginByToken: (token: string, user: User) => void;
logout: () => void; logout: () => void;
tokenAuthLoading: boolean; tokenAuthLoading: boolean;
tokenRefresh: () => Promise<void>;
tokenVerifyLoading: boolean; tokenVerifyLoading: boolean;
user?: User; user?: User;
} }
@ -28,6 +29,7 @@ export const UserContext = React.createContext<UserContext>({
loginByToken: undefined, loginByToken: undefined,
logout: undefined, logout: undefined,
tokenAuthLoading: false, tokenAuthLoading: false,
tokenRefresh: undefined,
tokenVerifyLoading: false tokenVerifyLoading: false
}); });

View file

@ -9,6 +9,7 @@ import {
import { SetPassword, SetPasswordVariables } from "./types/SetPassword"; import { SetPassword, SetPasswordVariables } from "./types/SetPassword";
import { TokenAuth, TokenAuthVariables } from "./types/TokenAuth"; import { TokenAuth, TokenAuthVariables } from "./types/TokenAuth";
import { VerifyToken, VerifyTokenVariables } from "./types/VerifyToken"; import { VerifyToken, VerifyTokenVariables } from "./types/VerifyToken";
import { RefreshToken, RefreshTokenVariables } from "./types/RefreshToken";
export const fragmentUser = gql` export const fragmentUser = gql`
fragment User on User { fragment User on User {
@ -98,3 +99,16 @@ export const SetPasswordMutation = TypedMutation<
SetPassword, SetPassword,
SetPasswordVariables SetPasswordVariables
>(setPassword); >(setPassword);
const refreshToken = gql`
mutation RefreshToken($token: String!) {
tokenRefresh(token: $token) {
token
payload
}
}
`;
export const TokenRefreshMutation = TypedMutation<
RefreshToken,
RefreshTokenVariables
>(refreshToken);

View file

@ -0,0 +1,21 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL mutation operation: RefreshToken
// ====================================================
export interface RefreshToken_tokenRefresh {
__typename: "RefreshToken";
token: string | null;
payload: any | null;
}
export interface RefreshToken {
tokenRefresh: RefreshToken_tokenRefresh | null;
}
export interface RefreshTokenVariables {
token: string;
}

View file

@ -10,7 +10,9 @@ import { useIntl } from "react-intl";
import { commonMessages } from "@saleor/intl"; import { commonMessages } from "@saleor/intl";
import { maybe, getMutationStatus } from "@saleor/misc"; import { maybe, getMutationStatus } from "@saleor/misc";
import { MutationResultAdditionalProps } from "@saleor/types"; import { MutationResultAdditionalProps } from "@saleor/types";
import { isJwtError } from "@saleor/auth/errors";
import useNotifier from "./useNotifier"; import useNotifier from "./useNotifier";
import useUser from "./useUser";
export type UseMutation<TData, TVariables> = [ export type UseMutation<TData, TVariables> = [
MutationFunction<TData, TVariables>, MutationFunction<TData, TVariables>,
@ -33,6 +35,8 @@ function makeMutation<TData, TVariables>(
}: UseMutationCbs<TData>): UseMutation<TData, TVariables> { }: UseMutationCbs<TData>): UseMutation<TData, TVariables> {
const notify = useNotifier(); const notify = useNotifier();
const intl = useIntl(); const intl = useIntl();
const user = useUser();
const [mutateFn, result] = useBaseMutation(mutation, { const [mutateFn, result] = useBaseMutation(mutation, {
onCompleted, onCompleted,
onError: (err: ApolloError) => { onError: (err: ApolloError) => {
@ -46,6 +50,11 @@ function makeMutation<TData, TVariables>(
notify({ notify({
text: intl.formatMessage(commonMessages.readOnly) text: intl.formatMessage(commonMessages.readOnly)
}); });
} else if (err.graphQLErrors.every(isJwtError)) {
user.logout();
notify({
text: intl.formatMessage(commonMessages.sessionExpired)
});
} else { } else {
notify({ notify({
text: intl.formatMessage(commonMessages.somethingWentWrong) text: intl.formatMessage(commonMessages.somethingWentWrong)

View file

@ -6,8 +6,10 @@ import { useIntl } from "react-intl";
import { commonMessages } from "@saleor/intl"; import { commonMessages } from "@saleor/intl";
import { maybe, RequireAtLeastOne } from "@saleor/misc"; import { maybe, RequireAtLeastOne } from "@saleor/misc";
import { isJwtError } from "@saleor/auth/errors";
import useAppState from "./useAppState"; import useAppState from "./useAppState";
import useNotifier from "./useNotifier"; import useNotifier from "./useNotifier";
import useUser from "./useUser";
export interface LoadMore<TData, TVariables> { export interface LoadMore<TData, TVariables> {
loadMore: ( loadMore: (
@ -38,6 +40,8 @@ function makeQuery<TData, TVariables>(
const notify = useNotifier(); const notify = useNotifier();
const intl = useIntl(); const intl = useIntl();
const [, dispatchAppState] = useAppState(); const [, dispatchAppState] = useAppState();
const user = useUser();
const queryData = useBaseQuery(query, { const queryData = useBaseQuery(query, {
context: { context: {
useBatching: true useBatching: true
@ -60,7 +64,12 @@ function makeQuery<TData, TVariables>(
}, [queryData.loading]); }, [queryData.loading]);
if (queryData.error) { if (queryData.error) {
if ( if (queryData.error.graphQLErrors.every(isJwtError)) {
user.logout();
notify({
text: intl.formatMessage(commonMessages.sessionExpired)
});
} else if (
!queryData.error.graphQLErrors.every( !queryData.error.graphQLErrors.every(
err => err =>
maybe(() => err.extensions.exception.code) === "PermissionDenied" maybe(() => err.extensions.exception.code) === "PermissionDenied"

View file

@ -17,6 +17,7 @@ import useAppState from "@saleor/hooks/useAppState";
import AttributeSection from "./attributes"; import AttributeSection from "./attributes";
import { attributeSection } from "./attributes/urls"; import { attributeSection } from "./attributes/urls";
import Auth, { getAuthToken, removeAuthToken } from "./auth"; import Auth, { getAuthToken, removeAuthToken } from "./auth";
import { isJwtError } from "./auth/errors";
import AuthProvider from "./auth/AuthProvider"; import AuthProvider from "./auth/AuthProvider";
import LoginLoading from "./auth/components/LoginLoading/LoginLoading"; import LoginLoading from "./auth/components/LoginLoading/LoginLoading";
import SectionRoute from "./auth/components/SectionRoute"; import SectionRoute from "./auth/components/SectionRoute";
@ -66,7 +67,10 @@ interface ResponseError extends ErrorResponse {
} }
const invalidTokenLink = onError((error: ResponseError) => { const invalidTokenLink = onError((error: ResponseError) => {
if (error.networkError && error.networkError.statusCode === 401) { if (
(error.networkError && error.networkError.statusCode === 401) ||
error.graphQLErrors?.some(isJwtError)
) {
removeAuthToken(); removeAuthToken();
} }
}); });

View file

@ -56,6 +56,9 @@ export const commonMessages = defineMessages({
savedChanges: { savedChanges: {
defaultMessage: "Saved changes" defaultMessage: "Saved changes"
}, },
sessionExpired: {
defaultMessage: "Your session has expired. Please log in again to continue."
},
somethingWentWrong: { somethingWentWrong: {
defaultMessage: "Saleor ran into an unexpected problem" defaultMessage: "Saleor ran into an unexpected problem"
}, },

View file

@ -8,6 +8,8 @@ import useNotifier from "./hooks/useNotifier";
import { commonMessages } from "./intl"; import { commonMessages } from "./intl";
import { maybe, getMutationStatus } from "./misc"; import { maybe, getMutationStatus } from "./misc";
import { MutationResultAdditionalProps } from "./types"; import { MutationResultAdditionalProps } from "./types";
import { isJwtError } from "./auth/errors";
import useUser from "./hooks/useUser";
export interface TypedMutationInnerProps<TData, TVariables> { export interface TypedMutationInnerProps<TData, TVariables> {
children: ( children: (
@ -27,6 +29,7 @@ export function TypedMutation<TData, TVariables>(
return (props: TypedMutationInnerProps<TData, TVariables>) => { return (props: TypedMutationInnerProps<TData, TVariables>) => {
const notify = useNotifier(); const notify = useNotifier();
const intl = useIntl(); const intl = useIntl();
const user = useUser();
const { children, onCompleted, onError, variables } = props; const { children, onCompleted, onError, variables } = props;
return ( return (
@ -44,6 +47,11 @@ export function TypedMutation<TData, TVariables>(
notify({ notify({
text: intl.formatMessage(commonMessages.readOnly) text: intl.formatMessage(commonMessages.readOnly)
}); });
} else if (err.graphQLErrors.every(isJwtError)) {
user.logout();
notify({
text: intl.formatMessage(commonMessages.sessionExpired)
});
} else { } else {
notify({ notify({
text: intl.formatMessage(commonMessages.somethingWentWrong) text: intl.formatMessage(commonMessages.somethingWentWrong)

View file

@ -9,6 +9,8 @@ import useAppState from "./hooks/useAppState";
import useNotifier from "./hooks/useNotifier"; import useNotifier from "./hooks/useNotifier";
import { commonMessages } from "./intl"; import { commonMessages } from "./intl";
import { maybe, RequireAtLeastOne } from "./misc"; import { maybe, RequireAtLeastOne } from "./misc";
import { isJwtError } from "./auth/errors";
import useUser from "./hooks/useUser";
export interface LoadMore<TData, TVariables> { export interface LoadMore<TData, TVariables> {
loadMore: ( loadMore: (
@ -65,9 +67,10 @@ export function TypedQuery<TData, TVariables>(
query: DocumentNode query: DocumentNode
): React.FC<TypedQueryInnerProps<TData, TVariables>> { ): React.FC<TypedQueryInnerProps<TData, TVariables>> {
return ({ children, displayLoader, skip, variables }) => { return ({ children, displayLoader, skip, variables }) => {
const pushMessage = useNotifier(); const notify = useNotifier();
const [, dispatchAppState] = useAppState(); const [, dispatchAppState] = useAppState();
const intl = useIntl(); const intl = useIntl();
const user = useUser();
return ( return (
<Query <Query
@ -80,14 +83,19 @@ export function TypedQuery<TData, TVariables>(
> >
{(queryData: QueryResult<TData, TVariables>) => { {(queryData: QueryResult<TData, TVariables>) => {
if (queryData.error) { if (queryData.error) {
if ( if (queryData.error.graphQLErrors.every(isJwtError)) {
user.logout();
notify({
text: intl.formatMessage(commonMessages.sessionExpired)
});
} else if (
!queryData.error.graphQLErrors.every( !queryData.error.graphQLErrors.every(
err => err =>
maybe(() => err.extensions.exception.code) === maybe(() => err.extensions.exception.code) ===
"PermissionDenied" "PermissionDenied"
) )
) { ) {
pushMessage({ notify({
text: intl.formatMessage(commonMessages.somethingWentWrong) text: intl.formatMessage(commonMessages.somethingWentWrong)
}); });
} }