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

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