feat: improve share security

This commit is contained in:
Elias Schneider
2022-10-13 23:23:33 +02:00
parent d9e5c286e3
commit 6358ac3918
15 changed files with 219 additions and 158 deletions

View File

@@ -27,6 +27,7 @@ export class AuthController {
}
@Post("signIn")
@HttpCode(200)
signIn(@Body() dto: AuthSignInDTO) {
return this.authService.signIn(dto);
}

View File

@@ -100,12 +100,12 @@ export class FileService {
);
}
verifyFileDownloadToken(shareId: string, fileId: string, token: string) {
verifyFileDownloadToken(shareId: string, token: string) {
try {
const claims = this.jwtService.verify(token, {
secret: this.config.get("JWT_SECRET"),
});
return claims.shareId == shareId && claims.fileId == fileId;
return claims.shareId == shareId;
} catch {
return false;
}

View File

@@ -1,23 +1,17 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class FileDownloadGuard implements CanActivate {
constructor(
private reflector: Reflector,
private fileService: FileService,
private prisma: PrismaService
) {}
constructor(private fileService: FileService) {}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
const token = request.query.token as string;
const { shareId, fileId } = request.params;
const { shareId } = request.params;
return this.fileService.verifyFileDownloadToken(shareId, fileId, token);
return this.fileService.verifyFileDownloadToken(shareId, token);
}
}

View File

@@ -1,5 +1,5 @@
import { Type } from "class-transformer";
import { IsString, Matches, ValidateNested } from "class-validator";
import { IsString, Length, Matches, ValidateNested } from "class-validator";
import { ShareSecurityDTO } from "./shareSecurity.dto";
export class CreateShareDTO {
@@ -7,6 +7,7 @@ export class CreateShareDTO {
@Matches("^[a-zA-Z0-9_-]*$", undefined, {
message: "ID only can contain letters, numbers, underscores and hyphens",
})
@Length(3, 50)
id: string;
@IsString()

View File

@@ -1,6 +1,5 @@
import { IsNotEmpty } from "class-validator";
export class SharePasswordDto {
@IsNotEmpty()
password: string;
}

View File

@@ -1,16 +1,16 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import {
CanActivate,
ExecutionContext,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { User } from "@prisma/client";
import { Request } from "express";
import { ExtractJwt } from "passport-jwt";
import { PrismaService } from "src/prisma/prisma.service";
import { ShareService } from "src/share/share.service";
@Injectable()
export class ShareOwnerGuard implements CanActivate {
constructor(
private prisma: PrismaService
) {}
constructor(private prisma: PrismaService) {}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
@@ -26,7 +26,7 @@ export class ShareOwnerGuard implements CanActivate {
include: { security: true },
});
if (!share) throw new NotFoundException("Share not found");
return share.creatorId == (request.user as User).id;
}

View File

@@ -21,6 +21,7 @@ export class ShareSecurityGuard implements CanActivate {
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
const shareToken = request.get("X-Share-Token");
const shareId = Object.prototype.hasOwnProperty.call(
request.params,
"shareId"
@@ -36,19 +37,15 @@ export class ShareSecurityGuard implements CanActivate {
if (!share || moment().isAfter(share.expiration))
throw new NotFoundException("Share not found");
if (!share.security) return true;
if (share.security.maxViews && share.security.maxViews <= share.views)
throw new ForbiddenException(
"Maximum views exceeded",
"share_max_views_exceeded"
);
if (
!this.shareService.verifyShareToken(shareId, request.get("X-Share-Token"))
)
if (share.security?.password && !shareToken)
throw new ForbiddenException(
"This share is password protected",
"share_password_required"
);
if (!this.shareService.verifyShareToken(shareId, shareToken))
throw new ForbiddenException(
"Share token required",
"share_token_required"
);

View File

@@ -0,0 +1,47 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import * as moment from "moment";
import { PrismaService } from "src/prisma/prisma.service";
import { ShareService } from "src/share/share.service";
@Injectable()
export class ShareTokenSecurity implements CanActivate {
constructor(
private reflector: Reflector,
private shareService: ShareService,
private prisma: PrismaService
) {}
async canActivate(context: ExecutionContext) {
const request: Request = context.switchToHttp().getRequest();
const shareId = Object.prototype.hasOwnProperty.call(
request.params,
"shareId"
)
? request.params.shareId
: request.params.id;
const share = await this.prisma.share.findUnique({
where: { id: shareId },
include: { security: true },
});
if (!share || moment().isAfter(share.expiration))
throw new NotFoundException("Share not found");
if (share.security?.maxViews && share.security.maxViews <= share.views)
throw new ForbiddenException(
"Maximum views exceeded",
"share_max_views_exceeded"
);
return true;
}
}

View File

@@ -18,6 +18,7 @@ import { ShareMetaDataDTO } from "./dto/shareMetaData.dto";
import { SharePasswordDto } from "./dto/sharePassword.dto";
import { ShareOwnerGuard } from "./guard/shareOwner.guard";
import { ShareSecurityGuard } from "./guard/shareSecurity.guard";
import { ShareTokenSecurity } from "./guard/shareTokenSecurity.guard";
import { ShareService } from "./share.service";
@Controller("shares")
@@ -68,11 +69,10 @@ export class ShareController {
return this.shareService.isShareIdAvailable(id);
}
@Post(":id/password")
async exchangeSharePasswordWithToken(
@Param("id") id: string,
@Body() body: SharePasswordDto
) {
return this.shareService.exchangeSharePasswordWithToken(id, body.password);
@HttpCode(200)
@UseGuards(ShareTokenSecurity)
@Post(":id/token")
async getShareToken(@Param("id") id: string, @Body() body: SharePasswordDto) {
return this.shareService.getShareToken(id, body.password);
}
}

View File

@@ -76,6 +76,9 @@ export class ShareService {
}
async complete(id: string) {
if (await this.isShareCompleted(id))
throw new BadRequestException("Share already completed");
const moreThanOneFileInShare =
(await this.prisma.file.findMany({ where: { shareId: id } })).length != 0;
@@ -117,8 +120,6 @@ export class ShareService {
return file;
});
await this.increaseViewCount(share);
return share;
}
@@ -160,27 +161,36 @@ export class ShareService {
});
}
async exchangeSharePasswordWithToken(shareId: string, password: string) {
const sharePassword = (
await this.prisma.shareSecurity.findFirst({
where: { share: { id: shareId } },
})
).password;
async getShareToken(shareId: string, password: string) {
const share = await this.prisma.share.findFirst({
where: { id: shareId },
include: {
security: true,
},
});
if (!(await argon.verify(sharePassword, password)))
if (
share?.security?.password &&
!(await argon.verify(share.security.password, password))
)
throw new ForbiddenException("Wrong password");
const token = this.generateShareToken(shareId);
const token = await this.generateShareToken(shareId);
await this.increaseViewCount(share);
return { token };
}
generateShareToken(shareId: string) {
async generateShareToken(shareId: string) {
const { expiration } = await this.prisma.share.findUnique({
where: { id: shareId },
});
console.log(moment(expiration).diff(new Date(), "seconds"));
return this.jwtService.sign(
{
shareId,
},
{
expiresIn: "1h",
expiresIn: moment(expiration).diff(new Date(), "seconds") + "s",
secret: this.config.get("JWT_SECRET"),
}
);