chore: Use httpOnly authentication cookie (#5552)

This commit is contained in:
Tom Moor
2023-07-15 16:56:32 -04:00
committed by GitHub
parent b1230d0c81
commit 39e12cef65
16 changed files with 114 additions and 120 deletions

View File

@@ -1,7 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import LoadingIndicator from "~/components/LoadingIndicator";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
@@ -22,17 +21,10 @@ const Authenticated = ({ children }: Props) => {
}, [i18n, language]);
if (auth.authenticated) {
const { user, team } = auth;
if (!team || !user) {
return <LoadingIndicator />;
}
return children;
}
void auth.logout(true);
return <Redirect to="/" />;
return <LoadingIndicator />;
};
export default observer(Authenticated);

View File

@@ -70,6 +70,7 @@ class WebsocketProvider extends React.Component<Props> {
transports: ["websocket"],
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
withCredentials: true,
});
invariant(this.socket, "Socket should be defined");
@@ -89,18 +90,6 @@ class WebsocketProvider extends React.Component<Props> {
fileOperations,
notifications,
} = this.props;
if (!auth.token) {
return;
}
this.socket.on("connect", () => {
// immediately send current users token to the websocket backend where it
// is verified, if all goes well an 'authenticated' message will be
// received in response
this.socket?.emit("authentication", {
token: auth.token,
});
});
// on reconnection, reset the transports option, as the Websocket
// connection may have failed (caused by proxy, firewall, browser, ...)

View File

@@ -1,8 +0,0 @@
import invariant from "invariant";
import useStores from "./useStores";
export default function useCurrentToken() {
const { auth } = useStores();
invariant(auth.token, "token is required");
return auth.token;
}

View File

@@ -8,7 +8,6 @@ import * as Y from "yjs";
import MultiplayerExtension from "@shared/editor/extensions/Multiplayer";
import Editor, { Props as EditorProps } from "~/components/Editor";
import env from "~/env";
import useCurrentToken from "~/hooks/useCurrentToken";
import useCurrentUser from "~/hooks/useCurrentUser";
import useIdle from "~/hooks/useIdle";
import useIsMounted from "~/hooks/useIsMounted";
@@ -45,8 +44,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
const history = useHistory();
const { t } = useTranslation();
const currentUser = useCurrentUser();
const { presence, ui } = useStores();
const token = useCurrentToken();
const { presence, auth, ui } = useStores();
const [showCursorNames, setShowCursorNames] = React.useState(false);
const [remoteProvider, setRemoteProvider] =
React.useState<HocuspocusProvider | null>(null);
@@ -54,6 +52,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
const [isRemoteSynced, setRemoteSynced] = React.useState(false);
const [ydoc] = React.useState(() => new Y.Doc());
const { showToast } = useToasts();
const token = auth.collaborationToken;
const isIdle = useIdle();
const isVisible = usePageVisibility();
const isMounted = useIsMounted();
@@ -188,8 +187,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
documentId,
ui,
presence,
token,
ydoc,
token,
currentUser.id,
isMounted,
]);

View File

@@ -5,6 +5,7 @@ import { getCookie, setCookie, removeCookie } from "tiny-cookie";
import { CustomTheme, TeamPreferences, UserPreferences } from "@shared/types";
import Storage from "@shared/utils/Storage";
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import { Hour } from "@shared/utils/time";
import RootStore from "~/stores/RootStore";
import Policy from "~/models/Policy";
import Team from "~/models/Team";
@@ -13,6 +14,7 @@ import env from "~/env";
import { client } from "~/utils/ApiClient";
import Desktop from "~/utils/Desktop";
import Logger from "~/utils/Logger";
import history from "~/utils/history";
const AUTH_STORE = "AUTH_STORE";
const NO_REDIRECT_PATHS = ["/", "/create", "/home", "/logout"];
@@ -20,6 +22,7 @@ const NO_REDIRECT_PATHS = ["/", "/create", "/home", "/logout"];
type PersistedData = {
user?: User;
team?: Team;
collaborationToken?: string;
availableTeams?: {
id: string;
name: string;
@@ -45,12 +48,19 @@ export type Config = {
};
export default class AuthStore {
/* The user that is currently signed in. */
@observable
user?: User | null;
/* The team that the current user is signed into. */
@observable
team?: Team | null;
/* A short-lived token to be used to authenticate with the collaboration server. */
@observable
collaborationToken?: string | null;
/* A list of teams that the current user has access to. */
@observable
availableTeams?: {
id: string;
@@ -60,24 +70,27 @@ export default class AuthStore {
isSignedIn: boolean;
}[];
@observable
token?: string | null;
/* A list of cancan policies for the current user. */
@observable
policies: Policy[] = [];
/* The authentication provider the user signed in with. */
@observable
lastSignedIn?: string | null;
/* Whether the user is currently saving their profile or team settings. */
@observable
isSaving = false;
/* Whether the user is currently suspended. */
@observable
isSuspended = false;
/* The email address to contact if the user is suspended. */
@observable
suspendedContactEmail?: string | null;
/* The auth configuration for the current domain. */
@observable
config: Config | null | undefined;
@@ -90,6 +103,9 @@ export default class AuthStore {
this.rehydrate(data);
// Refresh the auth store every 12 hours that the window is open
setInterval(this.fetch, 12 * Hour);
// persists this entire store to localstorage whenever any keys are changed
autorun(() => {
Storage.set(AUTH_STORE, this.asJson);
@@ -124,13 +140,10 @@ export default class AuthStore {
rehydrate(data: PersistedData) {
this.user = data.user ? new User(data.user, this) : undefined;
this.team = data.team ? new Team(data.team, this) : undefined;
this.token = getCookie("accessToken");
this.collaborationToken = data.collaborationToken;
this.lastSignedIn = getCookie("lastSignedIn");
this.addPolicies(data.policies);
if (this.token) {
setTimeout(() => this.fetch(), 0);
}
void this.fetch();
}
addPolicies(policies?: Policy[]) {
@@ -143,7 +156,7 @@ export default class AuthStore {
@computed
get authenticated(): boolean {
return !!this.token;
return !!this.user && !!this.team;
}
@computed
@@ -151,6 +164,7 @@ export default class AuthStore {
return {
user: this.user,
team: this.team,
collaborationToken: this.collaborationToken,
availableTeams: this.availableTeams,
policies: this.policies,
};
@@ -176,6 +190,7 @@ export default class AuthStore {
this.user = new User(user, this);
this.team = new Team(team, this);
this.availableTeams = res.data.availableTeams;
this.collaborationToken = res.data.collaborationToken;
if (env.SENTRY_DSN) {
Sentry.configureScope(function (scope) {
@@ -232,11 +247,11 @@ export default class AuthStore {
runInAction("AuthStore#updateUser", () => {
this.user = null;
this.team = null;
this.collaborationToken = null;
this.availableTeams = this.availableTeams?.filter(
(team) => team.id !== this.team?.id
);
this.policies = [];
this.token = null;
});
};
@@ -306,7 +321,12 @@ export default class AuthStore {
};
@action
logout = async (savePath = false) => {
logout = async (
/** Whether the current path should be saved and returned to after login */
savePath = false,
/** Whether the auth token is already expired. */
tokenIsExpired = false
) => {
// if this logout was forced from an authenticated route then
// save the current path so we can go back there once signed in
if (savePath) {
@@ -317,19 +337,15 @@ export default class AuthStore {
}
}
// If there is no auth token stored there is nothing else to do
if (!this.token) {
return;
// invalidate authentication token on server and unset auth cookie
if (!tokenIsExpired) {
try {
await client.post(`/auth.delete`);
} catch (err) {
Logger.error("Failed to delete authentication", err);
}
}
// invalidate authentication token on server
const promise = client.post(`/auth.delete`);
// remove authentication token itself
removeCookie("accessToken", {
path: "/",
});
// remove session record on apex cookie
const team = this.team;
@@ -344,17 +360,13 @@ export default class AuthStore {
// clear all credentials from cache (and local storage via autorun)
this.user = null;
this.team = null;
this.collaborationToken = null;
this.policies = [];
this.token = null;
// Tell the host application we logged out, if any allows window cleanup.
void Desktop.bridge?.onLogout?.();
this.rootStore.logout();
try {
await promise;
} catch (err) {
Logger.error("Failed to delete authentication", err);
}
history.replace("/");
};
}

View File

@@ -1,10 +1,8 @@
import retry from "fetch-retry";
import invariant from "invariant";
import { trim } from "lodash";
import queryString from "query-string";
import EDITOR_VERSION from "@shared/editor/version";
import stores from "~/stores";
import isCloudHosted from "~/utils/isCloudHosted";
import Logger from "./Logger";
import download from "./download";
import {
@@ -95,14 +93,8 @@ class ApiClient {
}
const headers = new Headers(headerOptions);
if (stores.auth.authenticated) {
invariant(stores.auth.token, "JWT token not set properly");
headers.set("Authorization", `Bearer ${stores.auth.token}`);
}
let response;
const timeStart = window.performance.now();
let response;
try {
response = await fetchWithRetry(urlToFetch, {
@@ -110,15 +102,7 @@ class ApiClient {
body,
headers,
redirect: "follow",
// For the hosted deployment we omit cookies on API requests as they are
// not needed for authentication this offers a performance increase.
// For self-hosted we include them to support a wide variety of
// authenticated proxies, e.g. Pomerium, Cloudflare Access etc.
credentials: options.credentials
? options.credentials
: isCloudHosted
? "omit"
: "same-origin",
credentials: "same-origin",
cache: "no-cache",
});
} catch (err) {
@@ -147,7 +131,8 @@ class ApiClient {
// Handle 401, log out user
if (response.status === 401) {
await stores.auth.logout();
const tokenIsExpired = true;
await stores.auth.logout(false, tokenIsExpired);
return;
}