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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 240 additions and 276 deletions

View File

@ -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",

View File

@ -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 (
<>

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: {

View File

@ -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() {

View File

@ -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 (
<>
<Helmet>
<title>Password Reset - {getAppName()}</title>
<title>
{t("Password Reset")} - {getAppName()}
</title>
</Helmet>
<Container my={40}>
<Text size="lg" ta="center">
Invalid or expired password reset link
{t("Invalid or expired password reset link")}
</Text>
<Group justify="center">
<Button
@ -35,7 +39,7 @@ export default function PasswordReset() {
variant="subtle"
size="md"
>
Goto login page
{t("Goto login page")}
</Button>
</Group>
</Container>
@ -46,7 +50,9 @@ export default function PasswordReset() {
return (
<>
<Helmet>
<title>Password Reset - {getAppName()}</title>
<title>
{t("Password Reset")} - {getAppName()}
</title>
</Helmet>
<PasswordResetForm resetToken={resetToken} />
</>

View File

@ -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,

View File

@ -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",

View File

@ -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;

View File

@ -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(),
});
}
}

View File

@ -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';
};

View File

@ -1,4 +0,0 @@
export interface TokensDto {
accessToken: string;
refreshToken: string;
}

View File

@ -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 };
}
}

View File

@ -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<string> {
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<TokensDto> {
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(),

View File

@ -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(),

View File

@ -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(),
});
}
}

View File

@ -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(

View File

@ -16,6 +16,16 @@ export class EnvironmentService {
);
}
isHttps(): boolean {
const appUrl = this.configService.get<string>('APP_URL');
try {
const url = new URL(appUrl);
return url.protocol === 'https:';
} catch (error) {
return false;
}
}
getPort(): number {
return parseInt(this.configService.get<string>('PORT', '3000'));
}
@ -44,7 +54,6 @@ export class EnvironmentService {
}
getFileUploadSizeLimit(): string {
return this.configService.get<string>('FILE_UPLOAD_SIZE_LIMIT', '50mb');
}

View File

@ -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<void> {
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();
}
}

View File

@ -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",

18
pnpm-lock.yaml generated
View File

@ -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