feat: enhance CORS configuration and implement throttling for API endpoints; add admin role checks in controllers
This commit is contained in:
@@ -23,6 +23,8 @@ MISTRAL_API_KEY=
|
|||||||
# Publik URL (används av frontend)
|
# Publik URL (används av frontend)
|
||||||
NEXT_PUBLIC_APP_URL=https://recept.gynther.se
|
NEXT_PUBLIC_APP_URL=https://recept.gynther.se
|
||||||
NEXT_PUBLIC_API_URL=https://recept.gynther.se
|
NEXT_PUBLIC_API_URL=https://recept.gynther.se
|
||||||
|
# CORS — tillåtna origins för backend-API (normalt samma som APP_URL)
|
||||||
|
ALLOWED_ORIGIN=https://recept.gynther.se
|
||||||
|
|
||||||
# Bootstrap-användare (skapas/uppdateras vid appstart)
|
# Bootstrap-användare (skapas/uppdateras vid appstart)
|
||||||
ADMIN_NADMIN_PASSWORD=byt-ut-mig
|
ADMIN_NADMIN_PASSWORD=byt-ut-mig
|
||||||
|
|||||||
@@ -10,23 +10,11 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext) {
|
canActivate(context: ExecutionContext) {
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const authHeader = request.headers.authorization;
|
|
||||||
const path = request.path;
|
|
||||||
const method = request.method;
|
|
||||||
console.log(`[JwtAuthGuard.canActivate] ${method} ${path}`);
|
|
||||||
console.log(`[JwtAuthGuard.canActivate] Authorization header:`, authHeader ? 'YES' : 'NO');
|
|
||||||
|
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
console.log(`[JwtAuthGuard.canActivate] isPublic:`, isPublic);
|
|
||||||
|
|
||||||
if (isPublic) return true;
|
if (isPublic) return true;
|
||||||
|
return super.canActivate(context);
|
||||||
const result = super.canActivate(context);
|
|
||||||
console.log(`[JwtAuthGuard.canActivate] super.canActivate result:`, result);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: { sub: number; username: string; role: string; isPremium: boolean }) {
|
async validate(payload: { sub: number; username: string; role: string; isPremium: boolean }) {
|
||||||
console.log('[JwtStrategy.validate] Payload received:', payload);
|
return { userId: payload.sub, username: payload.username, role: payload.role ?? 'user', isPremium: payload.isPremium ?? false };
|
||||||
const result = { userId: payload.sub, username: payload.username, role: payload.role ?? 'user', isPremium: payload.isPremium ?? false };
|
|
||||||
console.log('[JwtStrategy.validate] Returning user:', result);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import helmet from 'helmet';
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// CORS — tillåt endast appens egen origin (sätts via ALLOWED_ORIGIN i miljövariabler)
|
||||||
|
const allowedOrigin = process.env.ALLOWED_ORIGIN || 'https://recept.gynther.se';
|
||||||
|
app.enableCors({
|
||||||
|
origin: allowedOrigin,
|
||||||
|
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Helmet som säkerhetsbackup (CSP hanteras av Next.js/Caddy)
|
// Helmet som säkerhetsbackup (CSP hanteras av Next.js/Caddy)
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
|
|||||||
@@ -63,11 +63,13 @@ export class ProductsController {
|
|||||||
return this.productsService.findAllTags();
|
return this.productsService.findAllTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
@Get('duplicates')
|
@Get('duplicates')
|
||||||
findDuplicates() {
|
findDuplicates() {
|
||||||
return this.productsService.findDuplicateCandidates();
|
return this.productsService.findDuplicateCandidates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
@Get('merge-preview')
|
@Get('merge-preview')
|
||||||
previewMerge(
|
previewMerge(
|
||||||
@Query('sourceProductId', ParseIntPipe) sourceProductId: number,
|
@Query('sourceProductId', ParseIntPipe) sourceProductId: number,
|
||||||
@@ -130,10 +132,7 @@ export class ProductsController {
|
|||||||
|
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() body: CreateProductDto, @Request() req: any) {
|
create(@Body() body: CreateProductDto) {
|
||||||
console.log('[ProductsController.create] Request received');
|
|
||||||
console.log('[ProductsController.create] User:', req.user);
|
|
||||||
console.log('[ProductsController.create] Body:', body);
|
|
||||||
return this.productsService.create(body);
|
return this.productsService.create(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +150,7 @@ export class ProductsController {
|
|||||||
return this.productsService.merge(body.sourceProductId, body.targetProductId);
|
return this.productsService.merge(body.sourceProductId, body.targetProductId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
@Patch(':id/canonical-name')
|
@Patch(':id/canonical-name')
|
||||||
updateCanonicalName(
|
updateCanonicalName(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@@ -159,6 +159,7 @@ export class ProductsController {
|
|||||||
return this.productsService.updateCanonicalName(id, body.canonicalName);
|
return this.productsService.updateCanonicalName(id, body.canonicalName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
@Put(':id/tags')
|
@Put(':id/tags')
|
||||||
setTags(
|
setTags(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@@ -167,6 +168,7 @@ export class ProductsController {
|
|||||||
return this.productsService.setTags(id, body.tags);
|
return this.productsService.setTags(id, body.tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
@Put(':id/nutrition')
|
@Put(':id/nutrition')
|
||||||
upsertNutrition(
|
upsertNutrition(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@@ -175,6 +177,7 @@ export class ProductsController {
|
|||||||
return this.productsService.upsertNutrition(id, body);
|
return this.productsService.upsertNutrition(id, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(
|
update(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import { QuickImportDto } from './dto/quick-import.dto';
|
import { QuickImportDto } from './dto/quick-import.dto';
|
||||||
@@ -9,6 +10,7 @@ export class QuickImportController {
|
|||||||
constructor(private readonly quickImportService: QuickImportService) {}
|
constructor(private readonly quickImportService: QuickImportService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@Throttle({ default: { ttl: 60_000, limit: 20 } })
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
storage: memoryStorage(),
|
storage: memoryStorage(),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
|
||||||
import { ReceiptAliasService } from './receipt-alias.service';
|
import { ReceiptAliasService } from './receipt-alias.service';
|
||||||
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
|
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
@Controller('receipt-aliases')
|
@Controller('receipt-aliases')
|
||||||
export class ReceiptAliasController {
|
export class ReceiptAliasController {
|
||||||
constructor(private readonly receiptAliasService: ReceiptAliasService) {}
|
constructor(private readonly receiptAliasService: ReceiptAliasService) {}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Request,
|
Request,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -12,7 +11,6 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import { ReceiptImportService } from './receipt-import.service';
|
import { ReceiptImportService } from './receipt-import.service';
|
||||||
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
|
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|
||||||
|
|
||||||
const ALLOWED_MIMES = [
|
const ALLOWED_MIMES = [
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
@@ -29,7 +27,6 @@ export class ReceiptImportController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@Throttle({ default: { ttl: 60_000, limit: 20 } })
|
@Throttle({ default: { ttl: 60_000, limit: 20 } })
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
storage: memoryStorage(),
|
storage: memoryStorage(),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ services:
|
|||||||
DATABASE_URL: "mysql://root:${MARIADB_ROOT_PASSWORD}@recipe-db:3306/${MARIADB_DATABASE}"
|
DATABASE_URL: "mysql://root:${MARIADB_ROOT_PASSWORD}@recipe-db:3306/${MARIADB_DATABASE}"
|
||||||
MISTRAL_API_KEY: "${MISTRAL_API_KEY:-}"
|
MISTRAL_API_KEY: "${MISTRAL_API_KEY:-}"
|
||||||
JWT_SECRET: "${JWT_SECRET}"
|
JWT_SECRET: "${JWT_SECRET}"
|
||||||
|
ALLOWED_ORIGIN: "${NEXT_PUBLIC_APP_URL}"
|
||||||
ADMIN_NADMIN_PASSWORD: "${ADMIN_NADMIN_PASSWORD}"
|
ADMIN_NADMIN_PASSWORD: "${ADMIN_NADMIN_PASSWORD}"
|
||||||
ADMIN_PADMIN_PASSWORD: "${ADMIN_PADMIN_PASSWORD}"
|
ADMIN_PADMIN_PASSWORD: "${ADMIN_PADMIN_PASSWORD}"
|
||||||
SEED_USER1_PASSWORD: "${SEED_USER1_PASSWORD}"
|
SEED_USER1_PASSWORD: "${SEED_USER1_PASSWORD}"
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { auth } from './auth';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export default auth((req) => {
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
if (pathname.startsWith('/admin')) {
|
||||||
|
const role = (req.auth?.user as any)?.role;
|
||||||
|
if (role !== 'admin') {
|
||||||
|
const loginUrl = new URL('/login', req.url);
|
||||||
|
loginUrl.searchParams.set('callbackUrl', pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/admin/:path*'],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user