refactor: switch to HttpOnly cookie (#660)

* Switch to httpOnly cookie
* create endpoint to retrieve temporary collaboration token

* cleanups
This commit is contained in:
Philip Okugbe
2025-01-22 22:11:11 +00:00
committed by GitHub
parent f2235fd2a2
commit 990612793f
29 changed files with 240 additions and 276 deletions

View File

@ -1,8 +1,7 @@
import Cookies from "js-cookie";
import { createJSONStorage, atomWithStorage } from "jotai/utils";
import { ITokens } from "../types/auth.types";
const cookieStorage = createJSONStorage<ITokens>(() => {
const cookieStorage = createJSONStorage<any>(() => {
return {
getItem: () => Cookies.get("authTokens"),
setItem: (key, value) => Cookies.set(key, value, { expires: 30 }),
@ -10,7 +9,7 @@ const cookieStorage = createJSONStorage<ITokens>(() => {
};
});
export const authTokensAtom = atomWithStorage<ITokens | null>(
export const authTokensAtom = atomWithStorage<any | null>(
"authTokens",
null,
cookieStorage,

View File

@ -2,14 +2,7 @@ import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import useAuth from "@/features/auth/hooks/use-auth";
import { IPasswordReset } from "@/features/auth/types/auth.types";
import {
Box,
Button,
Container,
PasswordInput,
Text,
Title,
} from "@mantine/core";
import { Box, Button, Container, PasswordInput, Title } from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next";

View File

@ -2,13 +2,13 @@ import { useState } from "react";
import {
forgotPassword,
login,
logout,
passwordReset,
setupWorkspace,
verifyUserToken,
} from "@/features/auth/services/auth-service";
import { useNavigate } from "react-router-dom";
import { useAtom } from "jotai";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
IForgotPassword,
@ -20,31 +20,26 @@ import {
import { notifications } from "@mantine/notifications";
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
import Cookies from "js-cookie";
import { jwtDecode } from "jwt-decode";
import APP_ROUTE from "@/lib/app-route.ts";
import { useQueryClient } from "@tanstack/react-query";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
export default function useAuth() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const [, setCurrentUser] = useAtom(currentUserAtom);
const [authToken, setAuthToken] = useAtom(authTokensAtom);
const queryClient = useQueryClient();
const handleSignIn = async (data: ILogin) => {
setIsLoading(true);
try {
const res = await login(data);
await login(data);
setIsLoading(false);
setAuthToken(res.tokens);
navigate(APP_ROUTE.HOME);
} catch (err) {
console.log(err);
setIsLoading(false);
console.log(err);
notifications.show({
message: err.response?.data.message,
color: "red",
@ -56,11 +51,8 @@ export default function useAuth() {
setIsLoading(true);
try {
const res = await acceptInvitation(data);
await acceptInvitation(data);
setIsLoading(false);
setAuthToken(res.tokens);
navigate(APP_ROUTE.HOME);
} catch (err) {
setIsLoading(false);
@ -77,9 +69,6 @@ export default function useAuth() {
try {
const res = await setupWorkspace(data);
setIsLoading(false);
setAuthToken(res.tokens);
navigate(APP_ROUTE.HOME);
} catch (err) {
setIsLoading(false);
@ -94,14 +83,11 @@ export default function useAuth() {
setIsLoading(true);
try {
const res = await passwordReset(data);
await passwordReset(data);
setIsLoading(false);
setAuthToken(res.tokens);
navigate(APP_ROUTE.HOME);
notifications.show({
message: "Password reset was successful",
message: t("Password reset was successful"),
});
} catch (err) {
setIsLoading(false);
@ -112,34 +98,10 @@ export default function useAuth() {
}
};
const handleIsAuthenticated = async () => {
if (!authToken) {
return false;
}
try {
const accessToken = authToken.accessToken;
const payload = jwtDecode(accessToken);
// true if jwt is active
const now = Date.now().valueOf() / 1000;
return payload.exp >= now;
} catch (err) {
console.log("invalid jwt token", err);
return false;
}
};
const hasTokens = (): boolean => {
return !!authToken;
};
const handleLogout = async () => {
setAuthToken(null);
setCurrentUser(null);
Cookies.remove("authTokens");
queryClient.clear();
window.location.replace(APP_ROUTE.AUTH.LOGIN);;
setCurrentUser(RESET);
await logout();
window.location.replace(APP_ROUTE.AUTH.LOGIN);
};
const handleForgotPassword = async (data: IForgotPassword) => {
@ -182,12 +144,10 @@ export default function useAuth() {
signIn: handleSignIn,
invitationSignup: handleInvitationSignUp,
setupWorkspace: handleSetupWorkspace,
isAuthenticated: handleIsAuthenticated,
forgotPassword: handleForgotPassword,
passwordReset: handlePasswordReset,
verifyUserToken: handleVerifyUserToken,
logout: handleLogout,
hasTokens,
isLoading,
};
}

View File

@ -1,19 +1,15 @@
import { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom";
import useAuth from "@/features/auth/hooks/use-auth.ts";
export function useRedirectIfAuthenticated() {
const { isAuthenticated } = useAuth();
const { data, isLoading } = useCurrentUser();
const navigate = useNavigate();
useEffect(() => {
const checkAuth = async () => {
const validAuth = await isAuthenticated();
if (validAuth) {
navigate("/home");
}
};
checkAuth();
}, [isAuthenticated]);
if (data && data?.user) {
navigate(APP_ROUTE.HOME);
}
}, [isLoading, data]);
}

View File

@ -1,14 +1,28 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { verifyUserToken } from "../services/auth-service";
import { IVerifyUserToken } from "../types/auth.types";
import { getCollabToken, verifyUserToken } from "../services/auth-service";
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
export function useVerifyUserTokenQuery(
verify: IVerifyUserToken,
): UseQueryResult<any, Error> {
return useQuery({
queryKey: ["verify-token", verify],
queryFn: () => verifyUserToken(verify),
enabled: !!verify.token,
staleTime: 0,
});
}
verify: IVerifyUserToken,
): UseQueryResult<any, Error> {
return useQuery({
queryKey: ["verify-token", verify],
queryFn: () => verifyUserToken(verify),
enabled: !!verify.token,
staleTime: 0,
});
}
export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
return useQuery({
queryKey: ["collab-token"],
queryFn: () => getCollabToken(),
staleTime: 24 * 60 * 60 * 1000, //24hrs
refetchInterval: 20 * 60 * 60 * 1000, //20hrs
retry: 10,
retryDelay: (retryAttempt) => {
// Exponential backoff: 5s, 10s, 20s, etc.
return 5000 * Math.pow(2, retryAttempt - 1);
},
});
}

View File

@ -1,51 +1,49 @@
import api from "@/lib/api-client";
import {
IChangePassword,
ICollabToken,
IForgotPassword,
ILogin,
IPasswordReset,
IRegister,
ISetupWorkspace,
ITokenResponse,
IVerifyUserToken,
} from "@/features/auth/types/auth.types";
export async function login(data: ILogin): Promise<ITokenResponse> {
const req = await api.post<ITokenResponse>("/auth/login", data);
return req.data;
export async function login(data: ILogin): Promise<void> {
await api.post<void>("/auth/login", data);
}
/*
export async function register(data: IRegister): Promise<ITokenResponse> {
const req = await api.post<ITokenResponse>("/auth/register", data);
return req.data;
}*/
export async function logout(): Promise<void> {
await api.post<void>("/auth/logout");
}
export async function changePassword(
data: IChangePassword
data: IChangePassword,
): Promise<IChangePassword> {
const req = await api.post<IChangePassword>("/auth/change-password", data);
return req.data;
}
export async function setupWorkspace(
data: ISetupWorkspace
): Promise<ITokenResponse> {
const req = await api.post<ITokenResponse>("/auth/setup", data);
data: ISetupWorkspace,
): Promise<any> {
const req = await api.post<any>("/auth/setup", data);
return req.data;
}
export async function forgotPassword(data: IForgotPassword): Promise<void> {
await api.post<any>("/auth/forgot-password", data);
await api.post<void>("/auth/forgot-password", data);
}
export async function passwordReset(
data: IPasswordReset
): Promise<ITokenResponse> {
const req = await api.post<any>("/auth/password-reset", data);
return req.data;
export async function passwordReset(data: IPasswordReset): Promise<void> {
await api.post<void>("/auth/password-reset", data);
}
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
return api.post<any>("/auth/verify-token", data);
}
export async function getCollabToken(): Promise<ICollabToken> {
const req = await api.post<ICollabToken>("/auth/collab-token");
return req.data;
}

View File

@ -16,15 +16,6 @@ export interface ISetupWorkspace {
password: string;
}
export interface ITokens {
accessToken: string;
refreshToken: string;
}
export interface ITokenResponse {
tokens: ITokens;
}
export interface IChangePassword {
oldPassword: string;
newPassword: string;
@ -43,3 +34,7 @@ export interface IVerifyUserToken {
token: string;
type: string;
}
export interface ICollabToken {
token: string;
}

View File

@ -15,7 +15,6 @@ import {
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom } from "jotai";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
@ -41,6 +40,7 @@ import {
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
interface PageEditorProps {
pageId: string;
@ -53,7 +53,6 @@ export default function PageEditor({
editable,
content,
}: PageEditorProps) {
const [token] = useAtom(authTokensAtom);
const collaborationURL = useCollaborationUrl();
const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom);
@ -68,6 +67,7 @@ export default function PageEditor({
);
const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
const { data } = useCollabToken();
const localProvider = useMemo(() => {
const provider = new IndexeddbPersistence(documentName, ydoc);
@ -77,14 +77,14 @@ export default function PageEditor({
});
return provider;
}, [pageId, ydoc]);
}, [pageId, ydoc, data?.token]);
const remoteProvider = useMemo(() => {
const provider = new HocuspocusProvider({
name: documentName,
url: collaborationURL,
document: ydoc,
token: token?.accessToken,
token: data?.token,
connect: false,
onStatus: (status) => {
if (status.status === "connected") {
@ -102,7 +102,7 @@ export default function PageEditor({
});
return provider;
}, [ydoc, pageId, token?.accessToken]);
}, [ydoc, pageId, data?.token]);
useLayoutEffect(() => {
remoteProvider.connect();

View File

@ -3,11 +3,42 @@ import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import React, { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { useTranslation } from "react-i18next";
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
import { io } from "socket.io-client";
import { SOCKET_URL } from "@/features/websocket/types";
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts";
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom);
const { data, isLoading, error } = useCurrentUser();
const { i18n } = useTranslation();
const [, setSocket] = useAtom(socketAtom);
// fetch collab token on load
const { data: collab } = useCollabToken();
useEffect(() => {
const newSocket = io(SOCKET_URL, {
transports: ["websocket"],
withCredentials: true,
});
// @ts-ignore
setSocket(newSocket);
newSocket.on("connect", () => {
console.log("ws connected");
});
return () => {
console.log("ws disconnected");
newSocket.disconnect();
};
}, []);
useQuerySubscription();
useTreeSocket();
useEffect(() => {
if (data && data.user && data.workspace) {

View File

@ -7,7 +7,6 @@ import {
IAcceptInvite,
} from "../types/workspace.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { ITokenResponse } from "@/features/auth/types/auth.types.ts";
export async function getWorkspace(): Promise<IWorkspace> {
const req = await api.post<IWorkspace>("/workspace/info");
@ -51,11 +50,8 @@ export async function createInvitation(data: ICreateInvite) {
return req.data;
}
export async function acceptInvitation(
data: IAcceptInvite,
): Promise<ITokenResponse> {
const req = await api.post("/workspace/invites/accept", data);
return req.data;
export async function acceptInvitation(data: IAcceptInvite): Promise<void> {
await api.post<void>("/workspace/invites/accept", data);
}
export async function resendInvitation(data: {