From 990612793ffd6037947b91de95392c333e9fe3cd Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 22 Jan 2025 22:11:11 +0000 Subject: [PATCH] refactor: switch to HttpOnly cookie (#660) * Switch to httpOnly cookie * create endpoint to retrieve temporary collaboration token * cleanups --- apps/client/package.json | 2 - apps/client/src/App.tsx | 37 ----------- .../features/auth/atoms/auth-tokens-atom.ts | 5 +- .../auth/components/password-reset-form.tsx | 9 +-- .../src/features/auth/hooks/use-auth.ts | 64 ++++--------------- .../hooks/use-redirect-if-authenticated.ts | 18 ++---- .../src/features/auth/queries/auth-query.tsx | 36 +++++++---- .../features/auth/services/auth-service.ts | 38 ++++++----- .../src/features/auth/types/auth.types.ts | 13 ++-- .../src/features/editor/page-editor.tsx | 10 +-- .../src/features/user/user-provider.tsx | 31 +++++++++ .../workspace/services/workspace-service.ts | 8 +-- apps/client/src/lib/api-client.ts | 34 ++-------- apps/client/src/pages/auth/password-reset.tsx | 16 +++-- apps/client/src/pages/page/page.tsx | 1 - apps/server/package.json | 1 + .../extensions/authentication.extension.ts | 8 ++- apps/server/src/core/auth/auth.controller.ts | 61 ++++++++++++++---- apps/server/src/core/auth/dto/jwt-payload.ts | 6 +- apps/server/src/core/auth/dto/tokens.dto.ts | 4 -- .../src/core/auth/services/auth.service.ts | 28 ++++---- .../src/core/auth/services/token.service.ts | 18 ++---- .../src/core/auth/strategies/jwt.strategy.ts | 10 +-- .../controllers/workspace.controller.ts | 15 ++++- .../services/workspace-invitation.service.ts | 4 +- .../environment/environment.service.ts | 11 +++- apps/server/src/ws/ws.gateway.ts | 9 ++- package.json | 1 + pnpm-lock.yaml | 18 ++---- 29 files changed, 240 insertions(+), 276 deletions(-) delete mode 100644 apps/server/src/core/auth/dto/tokens.dto.ts diff --git a/apps/client/package.json b/apps/client/package.json index a8f79c2e..4b83dcc6 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -26,7 +26,6 @@ "@tanstack/react-query": "^5.61.4", "axios": "^1.7.8", "clsx": "^2.1.1", - "date-fns": "^4.1.0", "emoji-mart": "^5.6.0", "file-saver": "^2.0.5", "i18next": "^23.14.0", @@ -34,7 +33,6 @@ "jotai": "^2.10.3", "jotai-optics": "^0.4.0", "js-cookie": "^3.0.5", - "jwt-decode": "^4.0.0", "katex": "0.16.21", "lowlight": "^3.2.0", "mermaid": "^11.4.0", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index f0ef858f..95389e06 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -10,14 +10,6 @@ import Groups from "@/pages/settings/group/groups"; import GroupInfo from "./pages/settings/group/group-info"; import Spaces from "@/pages/settings/space/spaces.tsx"; import { Error404 } from "@/components/ui/error-404.tsx"; -import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts"; -import { useAtom, useAtomValue } from "jotai"; -import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts"; -import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts"; -import { useEffect } from "react"; -import { io } from "socket.io-client"; -import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom.ts"; -import { SOCKET_URL } from "@/features/websocket/types"; import AccountPreferences from "@/pages/settings/account/account-preferences.tsx"; import SpaceHome from "@/pages/space/space-home.tsx"; import PageRedirect from "@/pages/page/page-redirect.tsx"; @@ -30,35 +22,6 @@ import { useTranslation } from "react-i18next"; export default function App() { const { t } = useTranslation(); - const [, setSocket] = useAtom(socketAtom); - const authToken = useAtomValue(authTokensAtom); - - useEffect(() => { - if (!authToken?.accessToken) { - return; - } - const newSocket = io(SOCKET_URL, { - transports: ["websocket"], - auth: { - token: authToken.accessToken, - }, - }); - - // @ts-ignore - setSocket(newSocket); - - newSocket.on("connect", () => { - console.log("ws connected"); - }); - - return () => { - console.log("ws disconnected"); - newSocket.disconnect(); - }; - }, [authToken?.accessToken]); - - useQuerySubscription(); - useTreeSocket(); return ( <> diff --git a/apps/client/src/features/auth/atoms/auth-tokens-atom.ts b/apps/client/src/features/auth/atoms/auth-tokens-atom.ts index 978d9a6b..533ecd2a 100644 --- a/apps/client/src/features/auth/atoms/auth-tokens-atom.ts +++ b/apps/client/src/features/auth/atoms/auth-tokens-atom.ts @@ -1,8 +1,7 @@ import Cookies from "js-cookie"; import { createJSONStorage, atomWithStorage } from "jotai/utils"; -import { ITokens } from "../types/auth.types"; -const cookieStorage = createJSONStorage(() => { +const cookieStorage = createJSONStorage(() => { return { getItem: () => Cookies.get("authTokens"), setItem: (key, value) => Cookies.set(key, value, { expires: 30 }), @@ -10,7 +9,7 @@ const cookieStorage = createJSONStorage(() => { }; }); -export const authTokensAtom = atomWithStorage( +export const authTokensAtom = atomWithStorage( "authTokens", null, cookieStorage, diff --git a/apps/client/src/features/auth/components/password-reset-form.tsx b/apps/client/src/features/auth/components/password-reset-form.tsx index 13d0307d..392fdcb7 100644 --- a/apps/client/src/features/auth/components/password-reset-form.tsx +++ b/apps/client/src/features/auth/components/password-reset-form.tsx @@ -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"; diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index 63f06287..40b7d2a6 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -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, }; } diff --git a/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts b/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts index ebe1236d..8961ea93 100644 --- a/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts +++ b/apps/client/src/features/auth/hooks/use-redirect-if-authenticated.ts @@ -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]); } diff --git a/apps/client/src/features/auth/queries/auth-query.tsx b/apps/client/src/features/auth/queries/auth-query.tsx index b2c52bb7..a4b76838 100644 --- a/apps/client/src/features/auth/queries/auth-query.tsx +++ b/apps/client/src/features/auth/queries/auth-query.tsx @@ -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 { - return useQuery({ - queryKey: ["verify-token", verify], - queryFn: () => verifyUserToken(verify), - enabled: !!verify.token, - staleTime: 0, - }); - } \ No newline at end of file + verify: IVerifyUserToken, +): UseQueryResult { + return useQuery({ + queryKey: ["verify-token", verify], + queryFn: () => verifyUserToken(verify), + enabled: !!verify.token, + staleTime: 0, + }); +} + +export function useCollabToken(): UseQueryResult { + 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); + }, + }); +} diff --git a/apps/client/src/features/auth/services/auth-service.ts b/apps/client/src/features/auth/services/auth-service.ts index 5c439724..07ece5b7 100644 --- a/apps/client/src/features/auth/services/auth-service.ts +++ b/apps/client/src/features/auth/services/auth-service.ts @@ -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 { - const req = await api.post("/auth/login", data); - return req.data; +export async function login(data: ILogin): Promise { + await api.post("/auth/login", data); } -/* -export async function register(data: IRegister): Promise { - const req = await api.post("/auth/register", data); - return req.data; -}*/ +export async function logout(): Promise { + await api.post("/auth/logout"); +} export async function changePassword( - data: IChangePassword + data: IChangePassword, ): Promise { const req = await api.post("/auth/change-password", data); return req.data; } export async function setupWorkspace( - data: ISetupWorkspace -): Promise { - const req = await api.post("/auth/setup", data); + data: ISetupWorkspace, +): Promise { + const req = await api.post("/auth/setup", data); return req.data; } export async function forgotPassword(data: IForgotPassword): Promise { - await api.post("/auth/forgot-password", data); + await api.post("/auth/forgot-password", data); } -export async function passwordReset( - data: IPasswordReset -): Promise { - const req = await api.post("/auth/password-reset", data); - return req.data; +export async function passwordReset(data: IPasswordReset): Promise { + await api.post("/auth/password-reset", data); } export async function verifyUserToken(data: IVerifyUserToken): Promise { return api.post("/auth/verify-token", data); } + +export async function getCollabToken(): Promise { + const req = await api.post("/auth/collab-token"); + return req.data; +} diff --git a/apps/client/src/features/auth/types/auth.types.ts b/apps/client/src/features/auth/types/auth.types.ts index 9ad8b2ef..24be326f 100644 --- a/apps/client/src/features/auth/types/auth.types.ts +++ b/apps/client/src/features/auth/types/auth.types.ts @@ -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; +} diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index a896a63a..79dbecad 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -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(); diff --git a/apps/client/src/features/user/user-provider.tsx b/apps/client/src/features/user/user-provider.tsx index c5711863..1c6e2310 100644 --- a/apps/client/src/features/user/user-provider.tsx +++ b/apps/client/src/features/user/user-provider.tsx @@ -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) { diff --git a/apps/client/src/features/workspace/services/workspace-service.ts b/apps/client/src/features/workspace/services/workspace-service.ts index 956a91eb..c8e9c893 100644 --- a/apps/client/src/features/workspace/services/workspace-service.ts +++ b/apps/client/src/features/workspace/services/workspace-service.ts @@ -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 { const req = await api.post("/workspace/info"); @@ -51,11 +50,8 @@ export async function createInvitation(data: ICreateInvite) { return req.data; } -export async function acceptInvitation( - data: IAcceptInvite, -): Promise { - const req = await api.post("/workspace/invites/accept", data); - return req.data; +export async function acceptInvitation(data: IAcceptInvite): Promise { + await api.post("/workspace/invites/accept", data); } export async function resendInvitation(data: { diff --git a/apps/client/src/lib/api-client.ts b/apps/client/src/lib/api-client.ts index 49c8773f..d80ce8a6 100644 --- a/apps/client/src/lib/api-client.ts +++ b/apps/client/src/lib/api-client.ts @@ -1,5 +1,4 @@ import axios, { AxiosInstance } from "axios"; -import Cookies from "js-cookie"; import Routes from "@/lib/app-route.ts"; const api: AxiosInstance = axios.create({ @@ -7,28 +6,6 @@ const api: AxiosInstance = axios.create({ withCredentials: true, }); -api.interceptors.request.use( - (config) => { - const tokenData = Cookies.get("authTokens"); - - let accessToken: string; - try { - accessToken = tokenData && JSON.parse(tokenData)?.accessToken; - } catch (err) { - console.log("invalid authTokens:", err.message); - Cookies.remove("authTokens"); - } - - if (accessToken) { - config.headers.Authorization = `Bearer ${accessToken}`; - } - return config; - }, - (error) => { - return Promise.reject(error); - } -); - api.interceptors.response.use( (response) => { // we need the response headers for these endpoints @@ -45,11 +22,14 @@ api.interceptors.response.use( (error) => { if (error.response) { switch (error.response.status) { - case 401: + case 401: { + const url = new URL(error.request.responseURL)?.pathname; + if (url === "/api/auth/collab-token") return; + // Handle unauthorized error - Cookies.remove("authTokens"); redirectToLogin(); break; + } case 403: // Handle forbidden error break; @@ -61,8 +41,6 @@ api.interceptors.response.use( .includes("workspace not found") ) { console.log("workspace not found"); - Cookies.remove("authTokens"); - if (window.location.pathname != Routes.AUTH.SETUP) { window.location.href = Routes.AUTH.SETUP; } @@ -76,7 +54,7 @@ api.interceptors.response.use( } } return Promise.reject(error); - } + }, ); function redirectToLogin() { diff --git a/apps/client/src/pages/auth/password-reset.tsx b/apps/client/src/pages/auth/password-reset.tsx index a01c681c..ae7d391b 100644 --- a/apps/client/src/pages/auth/password-reset.tsx +++ b/apps/client/src/pages/auth/password-reset.tsx @@ -4,9 +4,11 @@ import { Link, useSearchParams } from "react-router-dom"; import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query"; import { Button, Container, Group, Text } from "@mantine/core"; import APP_ROUTE from "@/lib/app-route"; -import {getAppName} from "@/lib/config.ts"; +import { getAppName } from "@/lib/config.ts"; +import { useTranslation } from "react-i18next"; export default function PasswordReset() { + const { t } = useTranslation(); const [searchParams] = useSearchParams(); const { data, isLoading, isError } = useVerifyUserTokenQuery({ token: searchParams.get("token"), @@ -22,11 +24,13 @@ export default function PasswordReset() { return ( <> - Password Reset - {getAppName()} + + {t("Password Reset")} - {getAppName()} + - Invalid or expired password reset link + {t("Invalid or expired password reset link")} @@ -46,7 +50,9 @@ export default function PasswordReset() { return ( <> - Password Reset - {getAppName()} + + {t("Password Reset")} - {getAppName()} + diff --git a/apps/client/src/pages/page/page.tsx b/apps/client/src/pages/page/page.tsx index cc6a80b7..ce41816e 100644 --- a/apps/client/src/pages/page/page.tsx +++ b/apps/client/src/pages/page/page.tsx @@ -6,7 +6,6 @@ import { Helmet } from "react-helmet-async"; import PageHeader from "@/features/page/components/header/page-header.tsx"; import { extractPageSlugId } from "@/lib"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; -import { useMemo } from "react"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { SpaceCaslAction, diff --git a/apps/server/package.json b/apps/server/package.json index 26b172ee..619acc06 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -53,6 +53,7 @@ "bullmq": "^5.29.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cookie": "^1.0.2", "fix-esm": "^1.0.1", "fs-extra": "^11.2.0", "happy-dom": "^15.11.6", diff --git a/apps/server/src/collaboration/extensions/authentication.extension.ts b/apps/server/src/collaboration/extensions/authentication.extension.ts index fac72a08..4cb52c54 100644 --- a/apps/server/src/collaboration/extensions/authentication.extension.ts +++ b/apps/server/src/collaboration/extensions/authentication.extension.ts @@ -12,6 +12,7 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils'; import { SpaceRole } from '../../common/helpers/types/permission'; import { getPageId } from '../collaboration.util'; +import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload'; @Injectable() export class AuthenticationExtension implements Extension { @@ -28,12 +29,15 @@ export class AuthenticationExtension implements Extension { const { documentName, token } = data; const pageId = getPageId(documentName); - let jwtPayload = null; + let jwtPayload: JwtCollabPayload; try { jwtPayload = await this.tokenService.verifyJwt(token); } catch (error) { - throw new UnauthorizedException('Could not verify jwt token'); + throw new UnauthorizedException('Invalid collab token'); + } + if (jwtPayload.type !== JwtType.COLLAB) { + throw new UnauthorizedException(); } const userId = jwtPayload.sub; diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index 05540621..b4115b74 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -6,6 +6,7 @@ import { NotFoundException, Post, Req, + Res, UseGuards, } from '@nestjs/common'; import { LoginDto } from './dto/login.dto'; @@ -21,6 +22,8 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { ForgotPasswordDto } from './dto/forgot-password.dto'; import { PasswordResetDto } from './dto/password-reset.dto'; import { VerifyUserTokenDto } from './dto/verify-user-token.dto'; +import { FastifyReply } from 'fastify'; +import { addDays } from 'date-fns'; @Controller('auth') export class AuthController { @@ -31,26 +34,29 @@ export class AuthController { @HttpCode(HttpStatus.OK) @Post('login') - async login(@Req() req, @Body() loginInput: LoginDto) { - return this.authService.login(loginInput, req.raw.workspaceId); + async login( + @Req() req, + @Res({ passthrough: true }) res: FastifyReply, + @Body() loginInput: LoginDto, + ) { + const authToken = await this.authService.login( + loginInput, + req.raw.workspaceId, + ); + this.setAuthCookie(res, authToken); } - /* @HttpCode(HttpStatus.OK) - @Post('register') - async register(@Req() req, @Body() createUserDto: CreateUserDto) { - return this.authService.register(createUserDto, req.raw.workspaceId); - } - */ - @UseGuards(SetupGuard) @HttpCode(HttpStatus.OK) @Post('setup') async setupWorkspace( - @Req() req, + @Res({ passthrough: true }) res: FastifyReply, @Body() createAdminUserDto: CreateAdminUserDto, ) { if (this.environmentService.isCloud()) throw new NotFoundException(); - return this.authService.setup(createAdminUserDto); + + const authToken = await this.authService.setup(createAdminUserDto); + this.setAuthCookie(res, authToken); } @UseGuards(JwtAuthGuard) @@ -76,10 +82,15 @@ export class AuthController { @HttpCode(HttpStatus.OK) @Post('password-reset') async passwordReset( + @Res({ passthrough: true }) res: FastifyReply, @Body() passwordResetDto: PasswordResetDto, @AuthWorkspace() workspace: Workspace, ) { - return this.authService.passwordReset(passwordResetDto, workspace.id); + const authToken = await this.authService.passwordReset( + passwordResetDto, + workspace.id, + ); + this.setAuthCookie(res, authToken); } @HttpCode(HttpStatus.OK) @@ -90,4 +101,30 @@ export class AuthController { ) { return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id); } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('collab-token') + async collabToken( + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + return this.authService.getCollabToken(user.id, workspace.id); + } + + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @Post('logout') + async logout(@Res({ passthrough: true }) res: FastifyReply) { + res.clearCookie('authToken'); + } + + setAuthCookie(res: FastifyReply, token: string) { + res.setCookie('authToken', token, { + httpOnly: true, + path: '/', + expires: addDays(new Date(), 30), + secure: this.environmentService.isHttps(), + }); + } } diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts index 660cf6c2..52a189ab 100644 --- a/apps/server/src/core/auth/dto/jwt-payload.ts +++ b/apps/server/src/core/auth/dto/jwt-payload.ts @@ -1,6 +1,6 @@ export enum JwtType { ACCESS = 'access', - REFRESH = 'refresh', + COLLAB = 'collab', } export type JwtPayload = { sub: string; @@ -9,8 +9,8 @@ export type JwtPayload = { type: 'access'; }; -export type JwtRefreshPayload = { +export type JwtCollabPayload = { sub: string; workspaceId: string; - type: 'refresh'; + type: 'collab'; }; diff --git a/apps/server/src/core/auth/dto/tokens.dto.ts b/apps/server/src/core/auth/dto/tokens.dto.ts deleted file mode 100644 index a162be32..00000000 --- a/apps/server/src/core/auth/dto/tokens.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface TokensDto { - accessToken: string; - refreshToken: string; -} diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index 6f0ace7f..4772fc62 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -7,7 +7,6 @@ import { import { LoginDto } from '../dto/login.dto'; import { CreateUserDto } from '../dto/create-user.dto'; import { TokenService } from './token.service'; -import { TokensDto } from '../dto/tokens.dto'; import { SignupService } from './signup.service'; import { CreateAdminUserDto } from '../dto/create-admin-user.dto'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; @@ -60,24 +59,17 @@ export class AuthService { user.lastLoginAt = new Date(); await this.userRepo.updateLastLogin(user.id, workspaceId); - const tokens: TokensDto = await this.tokenService.generateTokens(user); - return { tokens }; + return this.tokenService.generateAccessToken(user); } async register(createUserDto: CreateUserDto, workspaceId: string) { const user = await this.signupService.signup(createUserDto, workspaceId); - - const tokens: TokensDto = await this.tokenService.generateTokens(user); - - return { tokens }; + return this.tokenService.generateAccessToken(user); } async setup(createAdminUserDto: CreateAdminUserDto) { const user = await this.signupService.initialSetup(createAdminUserDto); - - const tokens: TokensDto = await this.tokenService.generateTokens(user); - - return { tokens }; + return this.tokenService.generateAccessToken(user); } async changePassword( @@ -186,7 +178,7 @@ export class AuthService { trx, ); - trx + await trx .deleteFrom('userTokens') .where('userId', '=', user.id) .where('type', '=', UserTokenType.FORGOT_PASSWORD) @@ -200,9 +192,7 @@ export class AuthService { template: emailTemplate, }); - const tokens: TokensDto = await this.tokenService.generateTokens(user); - - return { tokens }; + return this.tokenService.generateAccessToken(user); } async verifyUserToken( @@ -222,4 +212,12 @@ export class AuthService { throw new BadRequestException('Invalid or expired token'); } } + + async getCollabToken(userId: string, workspaceId: string) { + const token = await this.tokenService.generateCollabToken( + userId, + workspaceId, + ); + return { token }; + } } diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts index fd4d9b37..2df8d50e 100644 --- a/apps/server/src/core/auth/services/token.service.ts +++ b/apps/server/src/core/auth/services/token.service.ts @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { EnvironmentService } from '../../../integrations/environment/environment.service'; -import { TokensDto } from '../dto/tokens.dto'; -import { JwtPayload, JwtRefreshPayload, JwtType } from '../dto/jwt-payload'; +import { JwtCollabPayload, JwtPayload, JwtType } from '../dto/jwt-payload'; import { User } from '@docmost/db/types/entity.types'; @Injectable() @@ -22,26 +21,19 @@ export class TokenService { return this.jwtService.sign(payload); } - async generateRefreshToken( + async generateCollabToken( userId: string, workspaceId: string, ): Promise { - const payload: JwtRefreshPayload = { + const payload: JwtCollabPayload = { sub: userId, workspaceId, - type: JwtType.REFRESH, + type: JwtType.COLLAB, }; - const expiresIn = this.environmentService.getJwtTokenExpiresIn(); + const expiresIn = '24h'; return this.jwtService.sign(payload, { expiresIn }); } - async generateTokens(user: User): Promise { - return { - accessToken: await this.generateAccessToken(user), - refreshToken: await this.generateRefreshToken(user.id, user.workspaceId), - }; - } - async verifyJwt(token: string) { return this.jwtService.verifyAsync(token, { secret: this.environmentService.getAppSecret(), diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts index 5577ec37..5c2a06b9 100644 --- a/apps/server/src/core/auth/strategies/jwt.strategy.ts +++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts @@ -23,15 +23,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { ) { super({ jwtFromRequest: (req: FastifyRequest) => { - let accessToken = null; - - try { - accessToken = JSON.parse(req.cookies?.authTokens)?.accessToken; - } catch { - this.logger.debug('Failed to parse access token'); - } - - return accessToken || this.extractTokenFromHeader(req); + return req.cookies?.authToken || this.extractTokenFromHeader(req); }, ignoreExpiration: false, secretOrKey: environmentService.getAppSecret(), diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts index 670c817f..bb69e7d5 100644 --- a/apps/server/src/core/workspace/controllers/workspace.controller.ts +++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts @@ -6,6 +6,7 @@ import { HttpStatus, Post, Req, + Res, UseGuards, } from '@nestjs/common'; import { WorkspaceService } from '../services/workspace.service'; @@ -29,6 +30,9 @@ import { WorkspaceCaslAction, WorkspaceCaslSubject, } from '../../casl/interfaces/workspace-ability.type'; +import { addDays } from 'date-fns'; +import { FastifyReply } from 'fastify'; +import { EnvironmentService } from '../../../integrations/environment/environment.service'; @UseGuards(JwtAuthGuard) @Controller('workspace') @@ -37,6 +41,7 @@ export class WorkspaceController { private readonly workspaceService: WorkspaceService, private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly workspaceAbility: WorkspaceAbilityFactory, + private environmentService: EnvironmentService, ) {} @Public() @@ -218,10 +223,18 @@ export class WorkspaceController { async acceptInvite( @Body() acceptInviteDto: AcceptInviteDto, @Req() req: any, + @Res({ passthrough: true }) res: FastifyReply, ) { - return this.workspaceInvitationService.acceptInvitation( + const authToken = await this.workspaceInvitationService.acceptInvitation( acceptInviteDto, req.raw.workspaceId, ); + + res.setCookie('authToken', authToken, { + httpOnly: true, + path: '/', + expires: addDays(new Date(), 30), + secure: this.environmentService.isHttps(), + }); } } diff --git a/apps/server/src/core/workspace/services/workspace-invitation.service.ts b/apps/server/src/core/workspace/services/workspace-invitation.service.ts index 4438b754..35a7d217 100644 --- a/apps/server/src/core/workspace/services/workspace-invitation.service.ts +++ b/apps/server/src/core/workspace/services/workspace-invitation.service.ts @@ -24,7 +24,6 @@ import { TokenService } from '../../auth/services/token.service'; import { nanoIdGen } from '../../../common/helpers'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { executeWithPagination } from '@docmost/db/pagination/pagination'; -import { TokensDto } from '../../auth/dto/tokens.dto'; @Injectable() export class WorkspaceInvitationService { @@ -254,8 +253,7 @@ export class WorkspaceInvitationService { }); } - const tokens: TokensDto = await this.tokenService.generateTokens(newUser); - return { tokens }; + return this.tokenService.generateAccessToken(newUser); } async resendInvitation( diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index c40ec915..d8e7b34d 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -16,6 +16,16 @@ export class EnvironmentService { ); } + isHttps(): boolean { + const appUrl = this.configService.get('APP_URL'); + try { + const url = new URL(appUrl); + return url.protocol === 'https:'; + } catch (error) { + return false; + } + } + getPort(): number { return parseInt(this.configService.get('PORT', '3000')); } @@ -44,7 +54,6 @@ export class EnvironmentService { } getFileUploadSizeLimit(): string { - return this.configService.get('FILE_UPLOAD_SIZE_LIMIT', '50mb'); } diff --git a/apps/server/src/ws/ws.gateway.ts b/apps/server/src/ws/ws.gateway.ts index feb40df7..96670878 100644 --- a/apps/server/src/ws/ws.gateway.ts +++ b/apps/server/src/ws/ws.gateway.ts @@ -10,6 +10,7 @@ import { TokenService } from '../core/auth/services/token.service'; import { JwtType } from '../core/auth/dto/jwt-payload'; import { OnModuleDestroy } from '@nestjs/common'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; +import * as cookie from 'cookie'; @WebSocketGateway({ cors: { origin: '*' }, @@ -25,10 +26,11 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy { async handleConnection(client: Socket, ...args: any[]): Promise { try { - const token = await this.tokenService.verifyJwt( - client.handshake.auth?.token, - ); + const cookies = cookie.parse(client.handshake.headers.cookie); + const token = await this.tokenService.verifyJwt(cookies['authToken']); + if (token.type !== JwtType.ACCESS) { + client.emit('Unauthorized'); client.disconnect(); } @@ -42,6 +44,7 @@ export class WsGateway implements OnGatewayConnection, OnModuleDestroy { client.join([workspaceRoom, ...spaceRooms]); } catch (err) { + client.emit('Unauthorized'); client.disconnect(); } } diff --git a/package.json b/package.json index d122fe91..2a73172b 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@tiptap/suggestion": "^2.10.3", "bytes": "^3.1.2", "cross-env": "^7.0.3", + "date-fns": "^4.1.0", "dompurify": "^3.2.1", "fractional-indexing-jittered": "^0.9.1", "ioredis": "^5.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9cad6f7..459c311f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: cross-env: specifier: ^7.0.3 version: 7.0.3 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 dompurify: specifier: ^3.2.1 version: 3.2.1 @@ -245,9 +248,6 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - date-fns: - specifier: ^4.1.0 - version: 4.1.0 emoji-mart: specifier: ^5.6.0 version: 5.6.0 @@ -269,9 +269,6 @@ importers: js-cookie: specifier: ^3.0.5 version: 3.0.5 - jwt-decode: - specifier: ^4.0.0 - version: 4.0.0 katex: specifier: 0.16.21 version: 0.16.21 @@ -465,6 +462,9 @@ importers: class-validator: specifier: ^0.14.1 version: 0.14.1 + cookie: + specifier: ^1.0.2 + version: 1.0.2 fix-esm: specifier: ^1.0.1 version: 1.0.1 @@ -6352,10 +6352,6 @@ packages: jws@3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} - jwt-decode@4.0.0: - resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} - engines: {node: '>=18'} - katex@0.16.21: resolution: {integrity: sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==} hasBin: true @@ -15855,8 +15851,6 @@ snapshots: jwa: 1.4.1 safe-buffer: 5.2.1 - jwt-decode@4.0.0: {} - katex@0.16.21: dependencies: commander: 8.3.0