From 823bf134dddd2273458be4f75b069c05548b4b50 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 18 Feb 2025 18:35:22 -0800 Subject: [PATCH] Fix case where user can get stuck in a deleted organization if they were prev logged into it --- backend/package-lock.json | 2 +- backend/src/server/routes/v1/auth-router.ts | 44 +++------------- .../services/auth-token/auth-token-service.ts | 38 +++++++++++++- backend/src/services/auth/auth-fns.ts | 1 - frontend/src/hooks/api/users/queries.tsx | 51 ++++++++++--------- .../src/pages/auth/LoginPage/LoginPage.tsx | 1 + .../SignUpInvitePage/SignUpInvitePage.tsx | 28 +++++----- .../src/pages/middlewares/authenticate.tsx | 30 +++++++++-- 8 files changed, 114 insertions(+), 81 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 873dbb217..6373ef244 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,7 +21,7 @@ "@fastify/etag": "^5.1.0", "@fastify/formbody": "^7.4.0", "@fastify/helmet": "^11.1.1", - "@fastify/multipart": "8.3.1", + "@fastify/multipart": "^8.3.1", "@fastify/passport": "^2.4.0", "@fastify/rate-limit": "^9.0.0", "@fastify/request-context": "^5.1.0", diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index 46f856105..4465053ea 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -1,11 +1,9 @@ import jwt from "jsonwebtoken"; import { z } from "zod"; - import { getConfig } from "@app/lib/config/env"; -import { NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { authRateLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { AuthMode, AuthModeRefreshJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; +import { AuthMode, AuthTokenType } from "@app/services/auth/auth-type"; export const registerAuthRoutes = async (server: FastifyZodProvider) => { server.route({ @@ -21,18 +19,19 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), handler: async (req, res) => { + const { decodedToken } = await server.services.authToken.validateRefreshToken(req.cookies.jid); const appCfg = getConfig(); - if (req.auth.authMode === AuthMode.JWT) { - await server.services.login.logout(req.permission.id, req.auth.tokenVersionId); - } + + await server.services.login.logout(decodedToken.userId, decodedToken.tokenVersionId); + void res.cookie("jid", "", { httpOnly: true, path: "/", sameSite: "strict", secure: appCfg.HTTPS_ENABLED }); + return { message: "Successfully logged out" }; } }); @@ -69,37 +68,8 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const refreshToken = req.cookies.jid; + const { decodedToken, tokenVersion } = await server.services.authToken.validateRefreshToken(req.cookies.jid); const appCfg = getConfig(); - if (!refreshToken) - throw new NotFoundError({ - name: "AuthTokenNotFound", - message: "Failed to find refresh token" - }); - - const decodedToken = jwt.verify(refreshToken, appCfg.AUTH_SECRET) as AuthModeRefreshJwtTokenPayload; - if (decodedToken.authTokenType !== AuthTokenType.REFRESH_TOKEN) - throw new UnauthorizedError({ - message: "The token provided is not a refresh token", - name: "InvalidToken" - }); - - const tokenVersion = await server.services.authToken.getUserTokenSessionById( - decodedToken.tokenVersionId, - decodedToken.userId - ); - if (!tokenVersion) - throw new UnauthorizedError({ - message: "Valid token version not found", - name: "InvalidToken" - }); - - if (decodedToken.refreshVersion !== tokenVersion.refreshVersion) { - throw new UnauthorizedError({ - message: "Token version mismatch", - name: "InvalidToken" - }); - } const token = jwt.sign( { diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index d15fa4543..67920acf1 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import jwt from "jsonwebtoken"; import bcrypt from "bcrypt"; import { Knex } from "knex"; @@ -8,7 +9,7 @@ import { getConfig } from "@app/lib/config/env"; import { ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; -import { AuthModeJwtTokenPayload } from "../auth/auth-type"; +import { AuthModeJwtTokenPayload, AuthModeRefreshJwtTokenPayload, AuthTokenType } from "../auth/auth-type"; import { TUserDALFactory } from "../user/user-dal"; import { TTokenDALFactory } from "./auth-token-dal"; import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenForUserDTO } from "./auth-token-types"; @@ -100,6 +101,40 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu return token; }; + const validateRefreshToken = async (refreshToken?: string) => { + const appCfg = getConfig(); + if (!refreshToken) + throw new NotFoundError({ + name: "AuthTokenNotFound", + message: "Failed to find refresh token" + }); + + const decodedToken = jwt.verify(refreshToken, appCfg.AUTH_SECRET) as AuthModeRefreshJwtTokenPayload; + + if (decodedToken.authTokenType !== AuthTokenType.REFRESH_TOKEN) + throw new UnauthorizedError({ + message: "The token provided is not a refresh token", + name: "InvalidToken" + }); + + const tokenVersion = await getUserTokenSessionById(decodedToken.tokenVersionId, decodedToken.userId); + + if (!tokenVersion) + throw new UnauthorizedError({ + message: "Valid token version not found", + name: "InvalidToken" + }); + + if (decodedToken.refreshVersion !== tokenVersion.refreshVersion) { + throw new UnauthorizedError({ + message: "Token version mismatch", + name: "InvalidToken" + }); + } + + return { decodedToken, tokenVersion }; + }; + const validateTokenForUser = async ({ type, userId, @@ -183,6 +218,7 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu return { createTokenForUser, + validateRefreshToken, validateTokenForUser, getUserTokenSession, clearTokenSessionById, diff --git a/backend/src/services/auth/auth-fns.ts b/backend/src/services/auth/auth-fns.ts index 5f7aca812..c73b32c1e 100644 --- a/backend/src/services/auth/auth-fns.ts +++ b/backend/src/services/auth/auth-fns.ts @@ -2,7 +2,6 @@ import jwt from "jsonwebtoken"; import { getConfig } from "@app/lib/config/env"; import { ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; - import { AuthModeProviderJwtTokenPayload, AuthModeProviderSignUpTokenPayload, AuthTokenType } from "./auth-type"; export const validateProviderAuthToken = (providerToken: string, username?: string) => { diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 88486ee48..6b85af9eb 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -24,10 +24,10 @@ import { User, UserEnc } from "./types"; +import { queryClient } from "@app/hooks/api/reactQuery"; export const fetchUserDetails = async () => { const { data } = await apiRequest.get<{ user: User & UserEnc }>("/api/v1/user"); - return data.user; }; @@ -278,30 +278,33 @@ export const useRegisterUserAction = () => { }); }; -export const useLogoutUser = (keepQueryClient?: boolean) => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async () => { - await apiRequest.post("/api/v1/auth/logout"); - }, - onSuccess: () => { - setAuthToken(""); - // Delete the cookie by not setting a value; Alternatively clear the local storage - localStorage.removeItem("protectedKey"); - localStorage.removeItem("protectedKeyIV"); - localStorage.removeItem("protectedKeyTag"); - localStorage.removeItem("publicKey"); - localStorage.removeItem("encryptedPrivateKey"); - localStorage.removeItem("iv"); - localStorage.removeItem("tag"); - localStorage.removeItem("PRIVATE_KEY"); - localStorage.removeItem("orgData.id"); - sessionStorage.removeItem(SessionStorageKeys.CLI_TERMINAL_TOKEN); +export const logoutUser = async () => { + await apiRequest.post("/api/v1/auth/logout"); +}; - if (!keepQueryClient) { - queryClient.clear(); - } - } +// Utility function to clear session storage and query cache +export const clearSession = (keepQueryClient?: boolean) => { + setAuthToken(""); // Clear authentication token + localStorage.removeItem("protectedKey"); + localStorage.removeItem("protectedKeyIV"); + localStorage.removeItem("protectedKeyTag"); + localStorage.removeItem("publicKey"); + localStorage.removeItem("encryptedPrivateKey"); + localStorage.removeItem("iv"); + localStorage.removeItem("tag"); + localStorage.removeItem("PRIVATE_KEY"); + localStorage.removeItem("orgData.id"); + sessionStorage.removeItem(SessionStorageKeys.CLI_TERMINAL_TOKEN); + + if (!keepQueryClient) { + queryClient.clear(); // Clear React Query cache + } +}; + +export const useLogoutUser = (keepQueryClient?: boolean) => { + return useMutation({ + mutationFn: logoutUser, + onSuccess: () => clearSession(keepQueryClient) }); }; diff --git a/frontend/src/pages/auth/LoginPage/LoginPage.tsx b/frontend/src/pages/auth/LoginPage/LoginPage.tsx index c7f121bb1..74ced95ef 100644 --- a/frontend/src/pages/auth/LoginPage/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage/LoginPage.tsx @@ -33,6 +33,7 @@ export const LoginPage = () => { console.log("Error - Not logged in yet"); } }; + if (isLoggedIn()) { handleRedirects(); } diff --git a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx index 3252bc9f9..d3106e341 100644 --- a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx +++ b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx @@ -250,20 +250,24 @@ export const SignupInvitePage = () => { setStep(2); } else { const redirectExistingUser = async () => { - const { token: mfaToken, isMfaEnabled } = await selectOrganization({ - organizationId - }); + try { + const { token: mfaToken, isMfaEnabled } = await selectOrganization({ + organizationId + }); - if (isMfaEnabled) { - SecurityClient.setMfaToken(mfaToken); - toggleShowMfa.on(); - setMfaSuccessCallback(() => redirectExistingUser); - return; + if (isMfaEnabled) { + SecurityClient.setMfaToken(mfaToken); + toggleShowMfa.on(); + setMfaSuccessCallback(() => redirectExistingUser); + return; + } + + // user will be redirected to dashboard + // if not logged in gets kicked out to login + await navigateUserToOrg(navigate, organizationId); + } catch (err) { + navigate({ to: "/login" }); } - - // user will be redirected to dashboard - // if not logged in gets kicked out to login - await navigateUserToOrg(navigate, organizationId); }; await redirectExistingUser(); diff --git a/frontend/src/pages/middlewares/authenticate.tsx b/frontend/src/pages/middlewares/authenticate.tsx index 56b3fdbfa..93462a095 100644 --- a/frontend/src/pages/middlewares/authenticate.tsx +++ b/frontend/src/pages/middlewares/authenticate.tsx @@ -5,12 +5,15 @@ import { ROUTE_PATHS } from "@app/const/routes"; import { userKeys } from "@app/hooks/api"; import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries"; import { fetchUserDetails } from "@app/hooks/api/users/queries"; +import { AxiosError } from "axios"; +import { clearSession, logoutUser } from "@app/hooks/api/users/queries"; export const Route = createFileRoute("/_authenticate")({ beforeLoad: async ({ context, location }) => { if (!context.serverConfig.initialized) { throw redirect({ to: "/admin/signup" }); } + const data = await context.queryClient .ensureQueryData({ queryKey: authKeys.getAuthToken, @@ -27,14 +30,31 @@ export const Route = createFileRoute("/_authenticate")({ }); }); - if (!data.organizationId && location.pathname !== ROUTE_PATHS.Auth.PasswordSetupPage.path && location.pathname !== "/organization/none") { + if ( + !data.organizationId && + location.pathname !== ROUTE_PATHS.Auth.PasswordSetupPage.path && + location.pathname !== "/organization/none" + ) { throw redirect({ to: "/login/select-organization" }); } - const user = await context.queryClient.ensureQueryData({ - queryKey: userKeys.getUser, - queryFn: fetchUserDetails - }); + const user = await context.queryClient + .ensureQueryData({ + queryKey: userKeys.getUser, + queryFn: fetchUserDetails + }) + .catch(async (error) => { + const err = error as AxiosError; + if (err.response?.status === 403) { + // (dangtony98): this edge-case can occur if the user's token corresponds to an organization + // that has been deleted for which we must clear the refresh token in http-only cookie + clearSession(true); + await logoutUser(); + throw redirect({ + to: "/login" + }); + } + }); return { organizationId: data.organizationId as string, isAuthenticated: true, user }; }