fix(security): resolve all critical, high, and medium security vulnerabilities
Some checks failed
Deploy Canina / deploy (push) Waiting to run
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 11s

This commit is contained in:
پارسا آقایی 2026-09-23 22:04:30 +03:30
parent da26dd1c8d
commit dc88677169
33 changed files with 438 additions and 85 deletions

20
.opencode/.mcp.json Normal file
View File

@ -0,0 +1,20 @@
{
"_meta": {
"generatedBy": "hiai-opencode",
"version": 1,
"generatedAt": "2026-09-16T22:09:21.466Z"
},
"mcpServers": {
"sequential-thinking": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-sequential-thinking"
]
},
"grep_app": {
"type": "http",
"url": "https://mcp.grep.app"
}
}
}

View File

@ -9,6 +9,12 @@ declare const process: any;
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {
if (process.env.NODE_ENV === 'production' && process.env.FORCE_SEED_WIPE !== 'true') {
throw new Error(
'CRITICAL: Seed wiping is BLOCKED in production! Set FORCE_SEED_WIPE=true if you really intend to wipe the database.',
);
}
console.log('Wiping existing database records...'); console.log('Wiping existing database records...');
await prisma.reminderCompletion.deleteMany(); await prisma.reminderCompletion.deleteMany();
await prisma.reminder.deleteMany(); await prisma.reminder.deleteMany();
@ -31,9 +37,10 @@ async function main() {
console.log('Database successfully wiped.'); console.log('Database successfully wiped.');
console.log('Creating Admin User...'); console.log('Creating Admin User...');
const adminHashedPassword = await bcrypt.hash('admin123', 10); const rawAdminPassword = process.env.ADMIN_PASSWORD || 'AdminPassword_ChangeMe_2026!';
const adminHashedPassword = await bcrypt.hash(rawAdminPassword, 10);
await prisma.user.upsert({ await prisma.user.upsert({
where: { email: 'admin@canina.ir' }, where: { email: process.env.ADMIN_EMAIL || 'admin@canina.ir' },
update: { update: {
password: adminHashedPassword, password: adminHashedPassword,
role: 'Admin', role: 'Admin',
@ -42,7 +49,7 @@ async function main() {
id: '12345678-1234-1234-1234-123456789012', id: '12345678-1234-1234-1234-123456789012',
firstName: 'مدیر', firstName: 'مدیر',
lastName: 'سیستم', lastName: 'سیستم',
email: 'admin@canina.ir', email: process.env.ADMIN_EMAIL || 'admin@canina.ir',
password: adminHashedPassword, password: adminHashedPassword,
role: 'Admin', role: 'Admin',
mobile: '09120000001', mobile: '09120000001',

View File

@ -8,6 +8,7 @@ import {
Param, Param,
Query, Query,
UseGuards, UseGuards,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { AdminService, CouponInput } from './admin.service'; import { AdminService, CouponInput } from './admin.service';
import { ProductDto } from './dto/product.dto'; import { ProductDto } from './dto/product.dto';
@ -28,9 +29,13 @@ import {
import { CreateUserDto, UpdateUserDto } from './dto/user.dto'; import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
import { SettingsService } from '../settings/settings.service'; import { SettingsService } from '../settings/settings.service';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
@ApiTags('Admin - مدیریت سیستم') @ApiTags('Admin - مدیریت سیستم')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin', 'SUPER_ADMIN')
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
constructor( constructor(
@ -109,9 +114,20 @@ export class AdminController {
@Body('type') type: 'deposit' | 'withdrawal' | 'refund', @Body('type') type: 'deposit' | 'withdrawal' | 'refund',
@Body('description') description?: string, @Body('description') description?: string,
) { ) {
const numAmount = Number(amount);
if (!numAmount || isNaN(numAmount) || numAmount <= 0) {
throw new BadRequestException('مبلغ باید یک عدد مثبت باشد');
}
if (numAmount > 100_000_000_000) {
throw new BadRequestException('مبلغ بیش از حد مجاز است');
}
if (!['deposit', 'withdrawal', 'refund'].includes(type)) {
throw new BadRequestException('نوع تراکنش معتبر نیست');
}
const result = await this.adminService.adjustUserWallet( const result = await this.adminService.adjustUserWallet(
id, id,
Number(amount), numAmount,
type, type,
description, description,
); );

View File

@ -45,6 +45,6 @@ import { SettingsModule } from '../settings/settings.module';
SslService, SslService,
ApiKeysService, ApiKeysService,
], ],
exports: [ApiKeysService], exports: [ApiKeysService, MediaService],
}) })
export class AdminModule {} export class AdminModule {}

View File

@ -61,6 +61,16 @@ export class CouponInput {
targets?: CouponTargetInput[]; targets?: CouponTargetInput[];
} }
const ALLOWED_USER_ROLES = [
'User_PetOwner',
'User_Vet',
'User_Clinic',
'User_PetShop',
'Customer',
'Admin',
'SUPER_ADMIN',
];
@Injectable() @Injectable()
export class AdminService { export class AdminService {
constructor( constructor(
@ -244,6 +254,10 @@ export class AdminService {
? await bcrypt.hash(passwordToHash, 10) ? await bcrypt.hash(passwordToHash, 10)
: ''; : '';
if (dto.role && !ALLOWED_USER_ROLES.includes(dto.role)) {
throw new BadRequestException('نقش کاربری مشخص شده نامعتبر است');
}
return this.prisma.user.create({ return this.prisma.user.create({
data: { data: {
firstName: dto.firstName.trim(), firstName: dto.firstName.trim(),
@ -307,7 +321,12 @@ export class AdminService {
if (normalizedMobile !== undefined) updateData.mobile = normalizedMobile; if (normalizedMobile !== undefined) updateData.mobile = normalizedMobile;
if (normalizedEmail !== undefined) if (normalizedEmail !== undefined)
updateData.email = normalizedEmail || null; updateData.email = normalizedEmail || null;
if (dto.role !== undefined) updateData.role = dto.role; if (dto.role !== undefined) {
if (!ALLOWED_USER_ROLES.includes(dto.role)) {
throw new BadRequestException('نقش کاربری مشخص شده نامعتبر است');
}
updateData.role = dto.role;
}
if (dto.walletBalance !== undefined) { if (dto.walletBalance !== undefined) {
updateData.walletBalance = new Prisma.Decimal(dto.walletBalance); updateData.walletBalance = new Prisma.Decimal(dto.walletBalance);
} }
@ -322,6 +341,9 @@ export class AdminService {
} }
async updateUserRole(id: string, role: string) { async updateUserRole(id: string, role: string) {
if (!ALLOWED_USER_ROLES.includes(role)) {
throw new BadRequestException('نقش کاربری مشخص شده نامعتبر است');
}
return this.prisma.user.update({ return this.prisma.user.update({
where: { id }, where: { id },
data: { role }, data: { role },

View File

@ -19,9 +19,13 @@ import {
ApiResponse, ApiResponse,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
@ApiTags('Admin - مدیریت کلیدهای دسترسی (API Keys)') @ApiTags('Admin - مدیریت کلیدهای دسترسی (API Keys)')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin', 'SUPER_ADMIN')
@Controller('admin/api-keys') @Controller('admin/api-keys')
export class ApiKeysController { export class ApiKeysController {
constructor(private readonly apiKeysService: ApiKeysService) {} constructor(private readonly apiKeysService: ApiKeysService) {}

View File

@ -150,12 +150,15 @@ export class ApiKeysService {
}) })
.catch(() => {}); .catch(() => {});
const scopes = key.scopes.split(',').map((s) => s.trim().toLowerCase());
const hasAdminScope = scopes.includes('*') || scopes.includes('admin') || scopes.includes('all');
return { return {
id: key.id, id: key.id,
name: key.name, name: key.name,
role: 'ADMIN', // Grants admin level access for n8n automation role: hasAdminScope ? 'Admin' : 'USER',
email: 'apikey-system@canina.ir', email: 'apikey-system@canina.ir',
scopes: key.scopes.split(',').map((s) => s.trim()), scopes,
isApiKey: true, isApiKey: true,
}; };
} }

View File

@ -22,9 +22,13 @@ import {
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto'; import { PaginationDto } from '../common/dto/pagination.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
@ApiTags('Admin - مدیریت مقالات (بلاگ)') @ApiTags('Admin - مدیریت مقالات (بلاگ)')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin', 'SUPER_ADMIN')
@Controller('admin/blogs') @Controller('admin/blogs')
export class BlogsController { export class BlogsController {
constructor(private readonly blogsService: BlogsService) {} constructor(private readonly blogsService: BlogsService) {}

View File

@ -21,9 +21,13 @@ import {
import { PaginationDto } from '../common/dto/pagination.dto'; import { PaginationDto } from '../common/dto/pagination.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
@ApiTags('Admin - مدیریت دسته‌بندی‌ها') @ApiTags('Admin - مدیریت دسته‌بندی‌ها')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin', 'SUPER_ADMIN')
@Controller('admin/categories') @Controller('admin/categories')
export class CategoriesController { export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {} constructor(private readonly categoriesService: CategoriesService) {}

View File

@ -14,15 +14,26 @@ import {
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { MediaService } from './media.service'; import { MediaService } from './media.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'application/pdf',
];
@ApiTags('Admin - مدیریت رسانه (تصاویر)') @ApiTags('Admin - مدیریت رسانه (تصاویر)')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin', 'SUPER_ADMIN')
@Controller('admin/media') @Controller('admin/media')
export class MediaController { export class MediaController {
constructor(private readonly mediaService: MediaService) {} constructor(private readonly mediaService: MediaService) {}
@UseGuards(JwtAuthGuard)
@Get() @Get()
@ApiOperation({ summary: 'لیست فایل‌های رسانه' }) @ApiOperation({ summary: 'لیست فایل‌های رسانه' })
async getAllMedia() { async getAllMedia() {
@ -31,15 +42,31 @@ export class MediaController {
} }
@Post('upload') @Post('upload')
@ApiOperation({ summary: 'آپلود فایل جدید (عمومی/ادمین)' }) @ApiOperation({ summary: 'آپلود فایل جدید توسط ادمین' })
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(
FileInterceptor('file', {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit
},
fileFilter: (_req, file, callback) => {
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
return callback(
new BadRequestException(
'فرمت فایل مجاز نیست. تنها فرمت‌های تصویری و PDF پشتیبانی می‌شوند.',
),
false,
);
}
callback(null, true);
},
}),
)
async uploadFile(@UploadedFile() file: Express.Multer.File) { async uploadFile(@UploadedFile() file: Express.Multer.File) {
if (!file) throw new BadRequestException('File is missing'); if (!file) throw new BadRequestException('File is missing');
const data = await this.mediaService.uploadFile(file); const data = await this.mediaService.uploadFile(file);
return { success: true, data }; return { success: true, data };
} }
@UseGuards(JwtAuthGuard)
@Delete('bulk') @Delete('bulk')
@ApiOperation({ summary: 'حذف دسته‌جمعی فایل‌ها' }) @ApiOperation({ summary: 'حذف دسته‌جمعی فایل‌ها' })
async deleteManyMedia(@Body('ids') ids: string[]) { async deleteManyMedia(@Body('ids') ids: string[]) {
@ -50,7 +77,6 @@ export class MediaController {
return { success: true, data }; return { success: true, data };
} }
@UseGuards(JwtAuthGuard)
@Delete(':id') @Delete(':id')
@ApiOperation({ summary: 'حذف فایل' }) @ApiOperation({ summary: 'حذف فایل' })
async deleteMedia(@Param('id') id: string) { async deleteMedia(@Param('id') id: string) {
@ -58,7 +84,6 @@ export class MediaController {
return { success: true, data }; return { success: true, data };
} }
@UseGuards(JwtAuthGuard)
@Put(':id') @Put(':id')
@ApiOperation({ @ApiOperation({
summary: 'ویرایش متادیتای سئوی فایل (Alt, Title, Description, Caption)', summary: 'ویرایش متادیتای سئوی فایل (Alt, Title, Description, Caption)',

View File

@ -17,9 +17,13 @@ import {
import { PaginationDto } from '../common/dto/pagination.dto'; import { PaginationDto } from '../common/dto/pagination.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
@ApiTags('Admin - مدیریت حیوانات خانگی') @ApiTags('Admin - مدیریت حیوانات خانگی')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin', 'SUPER_ADMIN')
@Controller('admin/pets') @Controller('admin/pets')
export class PetsController { export class PetsController {
constructor(private readonly petsService: PetsService) {} constructor(private readonly petsService: PetsService) {}

View File

@ -8,13 +8,17 @@ import {
ApiQuery, ApiQuery,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
@ApiTags('Admin - گزارشات') @ApiTags('Admin - گزارشات')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin', 'SUPER_ADMIN')
@Controller('admin/reports') @Controller('admin/reports')
export class ReportsController { export class ReportsController {
constructor(private readonly reportsService: ReportsService) {} constructor(private readonly reportsService: ReportsService) {}
@UseGuards(JwtAuthGuard)
@Get() @Get()
@ApiOperation({ @ApiOperation({
summary: 'دریافت گزارشات داشبورد با قابلیت فیلتر زمانی و نوع کاربر', summary: 'دریافت گزارشات داشبورد با قابلیت فیلتر زمانی و نوع کاربر',

View File

@ -21,9 +21,13 @@ import {
import { PaginationDto } from '../common/dto/pagination.dto'; import { PaginationDto } from '../common/dto/pagination.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
@ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)') @ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin', 'SUPER_ADMIN')
@Controller('admin/wiki') @Controller('admin/wiki')
export class WikiController { export class WikiController {
constructor(private readonly wikiService: WikiService) {} constructor(private readonly wikiService: WikiService) {}

View File

@ -1,11 +1,29 @@
export const DEFAULT_JWT_SECRET = import * as crypto from 'crypto';
'canina_jwt_fallback_access_secret_32_characters_minimum_len_2026';
export const DEFAULT_JWT_REFRESH_SECRET = const devAccessSecret =
'canina_jwt_fallback_refresh_secret_32_characters_minimum_len_2026'; process.env.NODE_ENV !== 'production'
? crypto.randomBytes(32).toString('hex')
: undefined;
export const getJwtSecret = (): string => const devRefreshSecret =
process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET || DEFAULT_JWT_SECRET; process.env.NODE_ENV !== 'production'
? crypto.randomBytes(32).toString('hex')
: undefined;
export const getJwtRefreshSecret = (): string => export const getJwtSecret = (): string => {
process.env.JWT_REFRESH_SECRET || DEFAULT_JWT_REFRESH_SECRET; const secret = process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET;
if (secret) return secret;
if (process.env.NODE_ENV === 'production') {
throw new Error('FATAL SECURITY ERROR: JWT_ACCESS_SECRET must be defined in production!');
}
return devAccessSecret!;
};
export const getJwtRefreshSecret = (): string => {
const secret = process.env.JWT_REFRESH_SECRET;
if (secret) return secret;
if (process.env.NODE_ENV === 'production') {
throw new Error('FATAL SECURITY ERROR: JWT_REFRESH_SECRET must be defined in production!');
}
return devRefreshSecret!;
};

View File

@ -92,6 +92,7 @@ export class AuthController {
return this.authService.login(loginDto); return this.authService.login(loginDto);
} }
@Throttle({ default: { limit: 5, ttl: 300000 } }) // 5 attempts per 5 minutes
@Post('admin-login') @Post('admin-login')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'ورود ادمین به پنل مدیریت' }) @ApiOperation({ summary: 'ورود ادمین به پنل مدیریت' })
@ -116,11 +117,18 @@ export class AuthController {
return this.authService.refresh(req.user); return this.authService.refresh(req.user);
} }
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post('logout') @Post('logout')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'خروج از حساب کاربری' }) @ApiOperation({ summary: 'خروج از حساب کاربری و ابطال توکن' })
@ApiOkResponse({ description: 'با موفقیت خارج شدید' }) @ApiOkResponse({ description: 'با موفقیت خارج شدید' })
logout() { async logout(@Req() req: any) {
const authHeader = req.headers?.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.replace('Bearer ', '').trim();
await this.authService.blacklistToken(token);
}
return { success: true, message: 'با موفقیت خارج شدید' }; return { success: true, message: 'با موفقیت خارج شدید' };
} }
} }

View File

@ -8,11 +8,14 @@ import { UsersModule } from '../users/users.module';
import { AdminModule } from '../admin/admin.module'; import { AdminModule } from '../admin/admin.module';
import { getJwtSecret } from './auth.constants'; import { getJwtSecret } from './auth.constants';
import { RedisModule } from '../redis/redis.module';
@Module({ @Module({
imports: [ imports: [
UsersModule, UsersModule,
forwardRef(() => AdminModule), forwardRef(() => AdminModule),
PassportModule, PassportModule,
RedisModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
useFactory: () => ({ useFactory: () => ({
secret: getJwtSecret(), secret: getJwtSecret(),

View File

@ -155,10 +155,11 @@ export class AuthService {
const payload = { sub: user.id, phoneNumber: user.mobile }; const payload = { sub: user.id, phoneNumber: user.mobile };
const accessToken = this.jwtService.sign(payload); const accessToken = this.jwtService.sign(payload);
const { password: _p, ...safeUser } = user;
return { return {
success: true, success: true,
data: { data: {
user, user: safeUser,
accessToken, accessToken,
}, },
}; };
@ -210,10 +211,11 @@ export class AuthService {
const payload = { sub: user.id, phoneNumber: user.mobile }; const payload = { sub: user.id, phoneNumber: user.mobile };
const accessToken = this.jwtService.sign(payload); const accessToken = this.jwtService.sign(payload);
const { password: _p, ...safeUser } = user;
return { return {
success: true, success: true,
data: { data: {
user, user: safeUser,
accessToken, accessToken,
}, },
}; };
@ -251,18 +253,17 @@ export class AuthService {
const payload = { sub: user.id, phoneNumber: user.mobile }; const payload = { sub: user.id, phoneNumber: user.mobile };
const accessToken = this.jwtService.sign(payload); const accessToken = this.jwtService.sign(payload);
const { password: _pw, ...safeUser } = user;
return { return {
success: true, success: true,
data: { data: {
user, user: safeUser,
accessToken, accessToken,
}, },
}; };
} }
async adminLogin(body: AdminLoginInput) { async adminLogin(body: AdminLoginInput) {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@canina.ir';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
// First check database for user with role 'Admin' or 'SUPER_ADMIN' // First check database for user with role 'Admin' or 'SUPER_ADMIN'
const dbAdmin = await this.prisma.user.findFirst({ const dbAdmin = await this.prisma.user.findFirst({
@ -280,34 +281,59 @@ export class AuthService {
email: dbAdmin.email, email: dbAdmin.email,
role: dbAdmin.role, role: dbAdmin.role,
}; };
const { password: _p, ...safeUser } = dbAdmin;
return { return {
success: true, success: true,
data: { data: {
user: dbAdmin, user: safeUser,
accessToken: this.jwtService.sign(payload), accessToken: this.jwtService.sign(payload),
}, },
}; };
} }
} }
// Fallback to validated environment variable credentials // Fallback to validated environment variable credentials only if configured securely
if (body.email === adminEmail && body.password === adminPassword) { const adminEmail = process.env.ADMIN_EMAIL;
const payload = { const adminPassword = process.env.ADMIN_PASSWORD;
sub: '12345678-1234-1234-1234-123456789012',
email: body.email, if (
role: 'Admin', adminEmail &&
}; adminPassword &&
return { adminPassword.length >= 8 &&
success: true, body.email &&
data: { body.password
user: { ) {
id: '12345678-1234-1234-1234-123456789012', const emailMatches =
email: body.email, body.email.length === adminEmail.length &&
role: 'Admin', crypto.timingSafeEqual(
Buffer.from(crypto.createHash('sha256').update(body.email).digest()),
Buffer.from(crypto.createHash('sha256').update(adminEmail).digest()),
);
const passwordMatches =
crypto.timingSafeEqual(
Buffer.from(crypto.createHash('sha256').update(body.password).digest()),
Buffer.from(crypto.createHash('sha256').update(adminPassword).digest()),
);
if (emailMatches && passwordMatches) {
const payload = {
sub: '12345678-1234-1234-1234-123456789012',
email: adminEmail,
role: 'Admin',
};
return {
success: true,
data: {
user: {
id: '12345678-1234-1234-1234-123456789012',
email: body.email,
role: 'Admin',
},
accessToken: this.jwtService.sign(payload),
}, },
accessToken: this.jwtService.sign(payload), };
}, }
};
} }
throw new BadRequestException({ throw new BadRequestException({
@ -336,4 +362,21 @@ export class AuthService {
}, },
}; };
} }
async blacklistToken(token: string) {
try {
const decoded: any = this.jwtService.decode(token);
let ttl = 7 * 24 * 3600; // default 7 days
if (decoded && decoded.exp) {
const remaining = decoded.exp - Math.floor(Date.now() / 1000);
if (remaining > 0) {
ttl = remaining;
}
}
await this.redisService.set(`bl_token:${token}`, '1', ttl);
} catch {
// Fallback: cache for 7 days
await this.redisService.set(`bl_token:${token}`, '1', 7 * 24 * 3600);
}
}
} }

View File

@ -4,6 +4,9 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { getJwtSecret } from './auth.constants'; import { getJwtSecret } from './auth.constants';
import { RedisService } from '../redis/redis.service';
import type { Request } from 'express';
export interface JwtPayload { export interface JwtPayload {
sub: string; sub: string;
email?: string; email?: string;
@ -13,18 +16,37 @@ export interface JwtPayload {
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly usersService: UsersService) { constructor(
private readonly usersService: UsersService,
private readonly redisService: RedisService,
) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: getJwtSecret(), secretOrKey: getJwtSecret(),
passReqToCallback: true,
}); });
} }
async validate(payload: JwtPayload) { async validate(req: Request, payload: JwtPayload) {
// Bypass DB lookup for local admin user to prevent UUID casting errors const rawToken = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
if (rawToken) {
const isBlacklisted = await this.redisService.get(`bl_token:${rawToken}`);
if (isBlacklisted) {
throw new UnauthorizedException('این توکن باطل شده است. لطفاً مجدداً وارد شوید');
}
}
// Handle fallback admin ID only if ADMIN_EMAIL is configured and matches payload
if (payload.sub === '12345678-1234-1234-1234-123456789012') { if (payload.sub === '12345678-1234-1234-1234-123456789012') {
return { id: payload.sub, email: payload.email, role: payload.role }; const adminEmail = process.env.ADMIN_EMAIL;
if (adminEmail && payload.email === adminEmail) {
return {
id: payload.sub,
email: adminEmail,
role: 'Admin',
};
}
throw new UnauthorizedException('حساب کاربری مدیریت معتبر نیست');
} }
const user = await this.usersService.findById(payload.sub); const user = await this.usersService.findById(payload.sub);

View File

@ -52,5 +52,18 @@ export function validateEnv(config: Record<string, unknown>) {
throw new Error(`Environment Validation Error: ${messages}`); throw new Error(`Environment Validation Error: ${messages}`);
} }
if (validatedConfig.NODE_ENV === 'production') {
if (!validatedConfig.JWT_ACCESS_SECRET) {
throw new Error(
'Environment Validation Error: JWT_ACCESS_SECRET is strictly required in production!',
);
}
if (!validatedConfig.JWT_REFRESH_SECRET) {
throw new Error(
'Environment Validation Error: JWT_REFRESH_SECRET is strictly required in production!',
);
}
}
return validatedConfig; return validatedConfig;
} }

View File

@ -10,6 +10,8 @@ import { ROLES_KEY } from '../decorators/roles.decorator';
interface RequestWithUser { interface RequestWithUser {
user?: { user?: {
role?: string; role?: string;
isApiKey?: boolean;
scopes?: string[];
}; };
} }
@ -33,11 +35,23 @@ export class RolesGuard implements CanActivate {
throw new ForbiddenException('شما دسترسی لازم برای این بخش را ندارید'); throw new ForbiddenException('شما دسترسی لازم برای این بخش را ندارید');
} }
// If request originates from an API key, verify it has admin scope for admin-guarded routes
if (user.isApiKey) {
const scopes = Array.isArray(user.scopes) ? user.scopes : [];
const hasAdminScope =
scopes.includes('*') ||
scopes.includes('admin') ||
scopes.includes('all');
if (!hasAdminScope) {
throw new ForbiddenException(
'کلید API ارائه‌شده فاقد اسکوپ مدیریتی لازم برای این عملیات است',
);
}
}
const userRoleLower = user.role.toLowerCase(); const userRoleLower = user.role.toLowerCase();
const hasRole = requiredRoles.some( const hasRole = requiredRoles.some(
(r) => (r) => r.toLowerCase() === userRoleLower,
r.toLowerCase() === userRoleLower ||
(userRoleLower.includes('admin') && r.toLowerCase().includes('admin')),
); );
if (!hasRole) { if (!hasRole) {
throw new ForbiddenException('سطح دسترسی شما کافی نیست'); throw new ForbiddenException('سطح دسترسی شما کافی نیست');

View File

@ -1,9 +1,14 @@
import { Controller, Get, Res } from '@nestjs/common'; import { Controller, Get, Res, UseGuards } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger'; import { ApiExcludeController } from '@nestjs/swagger';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
import { Roles } from './decorators/roles.decorator';
import type { Response } from 'express'; import type { Response } from 'express';
@ApiExcludeController() @ApiExcludeController()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin', 'SUPER_ADMIN')
@Controller('metrics') @Controller('metrics')
export class MetricsController { export class MetricsController {
private static requestCount = 0; private static requestCount = 0;

View File

@ -19,18 +19,10 @@ import compression from 'compression';
async function bootstrap() { async function bootstrap() {
validateEnv(process.env); validateEnv(process.env);
process.env.JWT_ACCESS_SECRET =
process.env.JWT_ACCESS_SECRET ||
process.env.JWT_SECRET ||
'canina_jwt_fallback_access_secret_32_characters_minimum_len_2026';
process.env.JWT_REFRESH_SECRET =
process.env.JWT_REFRESH_SECRET ||
'canina_jwt_fallback_refresh_secret_32_characters_minimum_len_2026';
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Trust reverse proxy (Nginx / Docker ingress) to resolve real client IP from X-Forwarded-For // Trust 1 reverse proxy hop (Nginx / Cloudflare / Ingress) to prevent IP spoofing
app.set('trust proxy', true); app.set('trust proxy', 1);
// Serve static uploads folder with 1-year aggressive browser caching (immutable) // Serve static uploads folder with 1-year aggressive browser caching (immutable)
const staticUploadOptions = { const staticUploadOptions = {
@ -54,14 +46,44 @@ async function bootstrap() {
app.use( app.use(
helmet({ helmet({
contentSecurityPolicy: false, // Avoid blocking Swagger UI scripts and assets contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
connectSrc: ["'self'", 'https:'],
fontSrc: ["'self'", 'data:'],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
} : false,
crossOriginEmbedderPolicy: false,
}), }),
); );
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map((o) => o.trim())
: [
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:5173',
'https://canina.ir',
'https://www.canina.ir',
'https://admin.canina.ir',
];
app.enableCors({ app.enableCors({
origin: true, origin: (origin, callback) => {
// Allow requests with no origin (e.g. mobile apps, curl, server-to-server)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
credentials: true, credentials: true,
}); });

View File

@ -14,6 +14,22 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard'; import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator'; import { Roles } from '../common/decorators/roles.decorator';
import {
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { MediaService } from '../admin/media.service';
const ALLOWED_PRESCRIPTION_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'application/pdf',
];
export interface UserReqPayload { export interface UserReqPayload {
user: { user: {
id?: string; id?: string;
@ -25,7 +41,36 @@ export interface UserReqPayload {
@ApiTags('Prescriptions - نسخه و تاییدیه دارویی') @ApiTags('Prescriptions - نسخه و تاییدیه دارویی')
@Controller('prescriptions') @Controller('prescriptions')
export class PrescriptionsController { export class PrescriptionsController {
constructor(private readonly prescriptionsService: PrescriptionsService) {} constructor(
private readonly prescriptionsService: PrescriptionsService,
private readonly mediaService: MediaService,
) {}
@Post('upload')
@ApiOperation({ summary: 'آپلود تصویر یا فایل نسخه دارویی توسط کاربر' })
@UseInterceptors(
FileInterceptor('file', {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit
},
fileFilter: (_req, file, callback) => {
if (!ALLOWED_PRESCRIPTION_MIME_TYPES.includes(file.mimetype)) {
return callback(
new BadRequestException(
'فرمت فایل مجاز نیست. تنها فرمت‌های تصویری و PDF پشتیبانی می‌شوند.',
),
false,
);
}
callback(null, true);
},
}),
)
async uploadFile(@UploadedFile() file: Express.Multer.File) {
if (!file) throw new BadRequestException('فایل بارگذاری نشده است');
const data = await this.mediaService.uploadFile(file);
return { success: true, data };
}
@Post() @Post()
@ApiOperation({ summary: 'بارگذاری نسخه جدید توسط کاربر' }) @ApiOperation({ summary: 'بارگذاری نسخه جدید توسط کاربر' })

View File

@ -4,8 +4,10 @@ import { PrescriptionsService } from './prescriptions.service';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { SmsService } from '../common/services/sms.service'; import { SmsService } from '../common/services/sms.service';
import { AdminModule } from '../admin/admin.module';
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule, AdminModule],
controllers: [PrescriptionsController], controllers: [PrescriptionsController],
providers: [PrescriptionsService, SmsService], providers: [PrescriptionsService, SmsService],
exports: [PrescriptionsService], exports: [PrescriptionsService],

View File

@ -9,6 +9,7 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
this.client = new Redis({ this.client = new Redis({
host: process.env.REDIS_HOST || 'localhost', host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT) || 16379, port: Number(process.env.REDIS_PORT) || 16379,
password: process.env.REDIS_PASSWORD || undefined,
}); });
} }

View File

@ -39,6 +39,13 @@ export const DEFAULT_UI_TEXTS: Record<string, string> = {
contact_telegram: 'https://t.me/caninairan', contact_telegram: 'https://t.me/caninairan',
contact_instagram: 'https://instagram.com/caninairan', contact_instagram: 'https://instagram.com/caninairan',
// === Bank Payment Info ===
bank_card_number: '6219861974012071',
bank_card_display: '۶۲۱۹-۸۶۱۹-۷۴۰۱-۲۰۷۱',
bank_sheba_number: 'IR-65 0560 6118 2800 5725 1015 01',
bank_name: 'بانک سامان',
bank_account_owner: 'پارسادرمانی کنینا (نوواگارد) - پارسا آقایی',
// === Header / Navigation === // === Header / Navigation ===
shipping_notice: shipping_notice:
'ارسال رایگان برای سفارش‌های بالای ۱۵۰ هزار تومان — گارانتی اصالت کنینا آلمان', 'ارسال رایگان برای سفارش‌های بالای ۱۵۰ هزار تومان — گارانتی اصالت کنینا آلمان',

View File

@ -4,9 +4,9 @@ services:
container_name: canino_db_prod container_name: canino_db_prod
restart: always restart: always
environment: environment:
POSTGRES_USER: canino_prod POSTGRES_USER: ${POSTGRES_USER:-canino_prod}
POSTGRES_PASSWORD: caninopassword_prod POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: caninodb_prod POSTGRES_DB: ${POSTGRES_DB:-caninodb_prod}
volumes: volumes:
- canina_prod_db:/var/lib/postgresql/data - canina_prod_db:/var/lib/postgresql/data
networks: networks:
@ -15,6 +15,14 @@ services:
redis_prod: redis_prod:
image: redis:7-alpine image: redis:7-alpine
container_name: canino_redis_prod container_name: canino_redis_prod
command: >
sh -c 'if [ -n "$$REDIS_PASSWORD" ]; then
exec redis-server --requirepass "$$REDIS_PASSWORD";
else
exec redis-server;
fi'
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
restart: always restart: always
networks: networks:
- traefik_public - traefik_public
@ -29,9 +37,14 @@ services:
- 217.218.127.127 - 217.218.127.127
- 217.218.155.155 - 217.218.155.155
environment: environment:
- DATABASE_URL=postgresql://canino_prod:caninopassword_prod@db_prod:5432/caninodb_prod?schema=public - DATABASE_URL=postgresql://${POSTGRES_USER:-canino_prod}:${POSTGRES_PASSWORD}@db_prod:5432/${POSTGRES_DB:-caninodb_prod}?schema=public
- REDIS_HOST=redis_prod - REDIS_HOST=redis_prod
- REDIS_PORT=6379 - REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- JWT_ACCESS_SECRET=${JWT_ACCESS_SECRET}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@canina.ir}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- PORT=3000 - PORT=3000
volumes: volumes:
- canina_prod_uploads:/app/uploads - canina_prod_uploads:/app/uploads

View File

@ -69,7 +69,15 @@ export default function ProtectedRoute() {
); );
} }
if (!useAdminAuthStore.getState().isAuthenticated) { const user = useAdminAuthStore.getState().adminUser;
const isUserAuthenticated = useAdminAuthStore.getState().isAuthenticated;
if (!isUserAuthenticated || !user) {
return <Navigate to="/login" replace />;
}
const role = (user.role || '').toUpperCase();
if (role !== 'ADMIN' && role !== 'SUPER_ADMIN') {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }

View File

@ -137,6 +137,11 @@ export async function GET(
responseHeaders.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000, immutable'); responseHeaders.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000, immutable');
responseHeaders.set('X-Content-Type-Options', 'nosniff'); responseHeaders.set('X-Content-Type-Options', 'nosniff');
if (contentType === 'image/svg+xml') {
responseHeaders.set('Content-Security-Policy', "default-src 'none'; sandbox");
responseHeaders.set('Content-Disposition', 'inline; filename="image.svg"');
}
return new Response(backendRes.body, { return new Response(backendRes.body, {
status: backendRes.status, status: backendRes.status,
statusText: backendRes.statusText, statusText: backendRes.statusText,

View File

@ -34,7 +34,14 @@ async function handleRevalidate(request: NextRequest) {
} }
} }
const expectedSecret = process.env.REVALIDATION_SECRET || 'canina_revalidation_secret_key_2026'; const expectedSecret = process.env.REVALIDATION_SECRET;
if (!expectedSecret) {
return NextResponse.json(
{ success: false, message: 'Revalidation service is not configured' },
{ status: 503 }
);
}
if (!secret || secret !== expectedSecret) { if (!secret || secret !== expectedSecret) {
return NextResponse.json( return NextResponse.json(
{ success: false, message: 'Invalid revalidation secret token' }, { success: false, message: 'Invalid revalidation secret token' },

View File

@ -733,7 +733,7 @@ export default function CheckoutPage() {
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-lg"> <div className="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
<div> <div>
<span className="text-[10px] text-gray-500 block">صاحب حساب:</span> <span className="text-[10px] text-gray-500 block">صاحب حساب:</span>
<span className="font-bold text-xs text-gray-900">پارسادرمانی کنینا (نوواگارد)</span> <span className="font-bold text-xs text-gray-900">پارسادرمانی کنینا (نوواگارد) - به نام پارسا آقایی</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -98,7 +98,7 @@ export default function PrescriptionUploadModal({ isOpen, onClose }: Prescriptio
const api = (await import("../lib/services/api")).default; const api = (await import("../lib/services/api")).default;
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const uploadRes = await api.post("/admin/media/upload", formData, { const uploadRes = await api.post("/prescriptions/upload", formData, {
headers: { "Content-Type": "multipart/form-data" } headers: { "Content-Type": "multipart/form-data" }
}); });
const fileUrl = uploadRes.data?.data?.url || uploadRes.data?.url || uploadRes.data?.filename || file.name; const fileUrl = uploadRes.data?.data?.url || uploadRes.data?.url || uploadRes.data?.filename || file.name;

View File

@ -162,7 +162,7 @@ export default function TopUpModal({ isOpen, onClose, initialAmount }: TopUpModa
<div className="text-[11px] text-gray-700 space-y-1 font-mono dir-ltr bg-white p-2.5 rounded-xl border border-blue-100"> <div className="text-[11px] text-gray-700 space-y-1 font-mono dir-ltr bg-white p-2.5 rounded-xl border border-blue-100">
<div>کارت: <strong>۶۲۱۹-۸۶۱۹-۷۴۰۱-۲۰۷۱</strong> (بانک سامان)</div> <div>کارت: <strong>۶۲۱۹-۸۶۱۹-۷۴۰۱-۲۰۷۱</strong> (بانک سامان)</div>
<div>شبا: <strong>IR-65 0560 6118 2800 5725 1015 01</strong></div> <div>شبا: <strong>IR-65 0560 6118 2800 5725 1015 01</strong></div>
<div className="text-gray-500 font-sans text-[10px] pt-1 border-t">به نام: پارسا آقایی</div> <div className="text-gray-500 font-sans text-[10px] pt-1 border-t">به نام: پارسادرمانی کنینا (نوواگارد) - پارسا آقایی</div>
</div> </div>
<p className="text-[10px] font-bold text-blue-800 leading-relaxed pt-1"> <p className="text-[10px] font-bold text-blue-800 leading-relaxed pt-1">
پس از واریز، فیش توسط تیم پشتیبانی بررسی و تایید خواهد شد. پس از واریز، فیش توسط تیم پشتیبانی بررسی و تایید خواهد شد.