fix(security): resolve all critical, high, and medium security vulnerabilities
This commit is contained in:
parent
da26dd1c8d
commit
dc88677169
20
.opencode/.mcp.json
Normal file
20
.opencode/.mcp.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,12 @@ declare const process: any;
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
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...');
|
||||
await prisma.reminderCompletion.deleteMany();
|
||||
await prisma.reminder.deleteMany();
|
||||
@ -31,9 +37,10 @@ async function main() {
|
||||
console.log('Database successfully wiped.');
|
||||
|
||||
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({
|
||||
where: { email: 'admin@canina.ir' },
|
||||
where: { email: process.env.ADMIN_EMAIL || 'admin@canina.ir' },
|
||||
update: {
|
||||
password: adminHashedPassword,
|
||||
role: 'Admin',
|
||||
@ -42,7 +49,7 @@ async function main() {
|
||||
id: '12345678-1234-1234-1234-123456789012',
|
||||
firstName: 'مدیر',
|
||||
lastName: 'سیستم',
|
||||
email: 'admin@canina.ir',
|
||||
email: process.env.ADMIN_EMAIL || 'admin@canina.ir',
|
||||
password: adminHashedPassword,
|
||||
role: 'Admin',
|
||||
mobile: '09120000001',
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { AdminService, CouponInput } from './admin.service';
|
||||
import { ProductDto } from './dto/product.dto';
|
||||
@ -28,9 +29,13 @@ import {
|
||||
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('Admin - مدیریت سیستم')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin', 'SUPER_ADMIN')
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
constructor(
|
||||
@ -109,9 +114,20 @@ export class AdminController {
|
||||
@Body('type') type: 'deposit' | 'withdrawal' | 'refund',
|
||||
@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(
|
||||
id,
|
||||
Number(amount),
|
||||
numAmount,
|
||||
type,
|
||||
description,
|
||||
);
|
||||
|
||||
@ -45,6 +45,6 @@ import { SettingsModule } from '../settings/settings.module';
|
||||
SslService,
|
||||
ApiKeysService,
|
||||
],
|
||||
exports: [ApiKeysService],
|
||||
exports: [ApiKeysService, MediaService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@ -61,6 +61,16 @@ export class CouponInput {
|
||||
targets?: CouponTargetInput[];
|
||||
}
|
||||
|
||||
const ALLOWED_USER_ROLES = [
|
||||
'User_PetOwner',
|
||||
'User_Vet',
|
||||
'User_Clinic',
|
||||
'User_PetShop',
|
||||
'Customer',
|
||||
'Admin',
|
||||
'SUPER_ADMIN',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
@ -244,6 +254,10 @@ export class AdminService {
|
||||
? await bcrypt.hash(passwordToHash, 10)
|
||||
: '';
|
||||
|
||||
if (dto.role && !ALLOWED_USER_ROLES.includes(dto.role)) {
|
||||
throw new BadRequestException('نقش کاربری مشخص شده نامعتبر است');
|
||||
}
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
firstName: dto.firstName.trim(),
|
||||
@ -307,7 +321,12 @@ export class AdminService {
|
||||
if (normalizedMobile !== undefined) updateData.mobile = normalizedMobile;
|
||||
if (normalizedEmail !== undefined)
|
||||
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) {
|
||||
updateData.walletBalance = new Prisma.Decimal(dto.walletBalance);
|
||||
}
|
||||
@ -322,6 +341,9 @@ export class AdminService {
|
||||
}
|
||||
|
||||
async updateUserRole(id: string, role: string) {
|
||||
if (!ALLOWED_USER_ROLES.includes(role)) {
|
||||
throw new BadRequestException('نقش کاربری مشخص شده نامعتبر است');
|
||||
}
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { role },
|
||||
|
||||
@ -19,9 +19,13 @@ import {
|
||||
ApiResponse,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('Admin - مدیریت کلیدهای دسترسی (API Keys)')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin', 'SUPER_ADMIN')
|
||||
@Controller('admin/api-keys')
|
||||
export class ApiKeysController {
|
||||
constructor(private readonly apiKeysService: ApiKeysService) {}
|
||||
|
||||
@ -150,12 +150,15 @@ export class ApiKeysService {
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const scopes = key.scopes.split(',').map((s) => s.trim().toLowerCase());
|
||||
const hasAdminScope = scopes.includes('*') || scopes.includes('admin') || scopes.includes('all');
|
||||
|
||||
return {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
role: 'ADMIN', // Grants admin level access for n8n automation
|
||||
role: hasAdminScope ? 'Admin' : 'USER',
|
||||
email: 'apikey-system@canina.ir',
|
||||
scopes: key.scopes.split(',').map((s) => s.trim()),
|
||||
scopes,
|
||||
isApiKey: true,
|
||||
};
|
||||
}
|
||||
|
||||
@ -22,9 +22,13 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('Admin - مدیریت مقالات (بلاگ)')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin', 'SUPER_ADMIN')
|
||||
@Controller('admin/blogs')
|
||||
export class BlogsController {
|
||||
constructor(private readonly blogsService: BlogsService) {}
|
||||
|
||||
@ -21,9 +21,13 @@ import {
|
||||
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('Admin - مدیریت دستهبندیها')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin', 'SUPER_ADMIN')
|
||||
@Controller('admin/categories')
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categoriesService: CategoriesService) {}
|
||||
|
||||
@ -14,15 +14,26 @@ import {
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { MediaService } from './media.service';
|
||||
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';
|
||||
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'application/pdf',
|
||||
];
|
||||
|
||||
@ApiTags('Admin - مدیریت رسانه (تصاویر)')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin', 'SUPER_ADMIN')
|
||||
@Controller('admin/media')
|
||||
export class MediaController {
|
||||
constructor(private readonly mediaService: MediaService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'لیست فایلهای رسانه' })
|
||||
async getAllMedia() {
|
||||
@ -31,15 +42,31 @@ export class MediaController {
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@ApiOperation({ summary: 'آپلود فایل جدید (عمومی/ادمین)' })
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiOperation({ summary: 'آپلود فایل جدید توسط ادمین' })
|
||||
@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) {
|
||||
if (!file) throw new BadRequestException('File is missing');
|
||||
const data = await this.mediaService.uploadFile(file);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('bulk')
|
||||
@ApiOperation({ summary: 'حذف دستهجمعی فایلها' })
|
||||
async deleteManyMedia(@Body('ids') ids: string[]) {
|
||||
@ -50,7 +77,6 @@ export class MediaController {
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'حذف فایل' })
|
||||
async deleteMedia(@Param('id') id: string) {
|
||||
@ -58,7 +84,6 @@ export class MediaController {
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put(':id')
|
||||
@ApiOperation({
|
||||
summary: 'ویرایش متادیتای سئوی فایل (Alt, Title, Description, Caption)',
|
||||
|
||||
@ -17,9 +17,13 @@ import {
|
||||
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('Admin - مدیریت حیوانات خانگی')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin', 'SUPER_ADMIN')
|
||||
@Controller('admin/pets')
|
||||
export class PetsController {
|
||||
constructor(private readonly petsService: PetsService) {}
|
||||
|
||||
@ -8,13 +8,17 @@ import {
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('Admin - گزارشات')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin', 'SUPER_ADMIN')
|
||||
@Controller('admin/reports')
|
||||
export class ReportsController {
|
||||
constructor(private readonly reportsService: ReportsService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'دریافت گزارشات داشبورد با قابلیت فیلتر زمانی و نوع کاربر',
|
||||
|
||||
@ -21,9 +21,13 @@ import {
|
||||
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin', 'SUPER_ADMIN')
|
||||
@Controller('admin/wiki')
|
||||
export class WikiController {
|
||||
constructor(private readonly wikiService: WikiService) {}
|
||||
|
||||
@ -1,11 +1,29 @@
|
||||
export const DEFAULT_JWT_SECRET =
|
||||
'canina_jwt_fallback_access_secret_32_characters_minimum_len_2026';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export const DEFAULT_JWT_REFRESH_SECRET =
|
||||
'canina_jwt_fallback_refresh_secret_32_characters_minimum_len_2026';
|
||||
const devAccessSecret =
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? crypto.randomBytes(32).toString('hex')
|
||||
: undefined;
|
||||
|
||||
export const getJwtSecret = (): string =>
|
||||
process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET || DEFAULT_JWT_SECRET;
|
||||
const devRefreshSecret =
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? crypto.randomBytes(32).toString('hex')
|
||||
: undefined;
|
||||
|
||||
export const getJwtRefreshSecret = (): string =>
|
||||
process.env.JWT_REFRESH_SECRET || DEFAULT_JWT_REFRESH_SECRET;
|
||||
export const getJwtSecret = (): string => {
|
||||
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!;
|
||||
};
|
||||
|
||||
@ -92,6 +92,7 @@ export class AuthController {
|
||||
return this.authService.login(loginDto);
|
||||
}
|
||||
|
||||
@Throttle({ default: { limit: 5, ttl: 300000 } }) // 5 attempts per 5 minutes
|
||||
@Post('admin-login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'ورود ادمین به پنل مدیریت' })
|
||||
@ -116,11 +117,18 @@ export class AuthController {
|
||||
return this.authService.refresh(req.user);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'خروج از حساب کاربری' })
|
||||
@ApiOperation({ summary: 'خروج از حساب کاربری و ابطال توکن' })
|
||||
@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: 'با موفقیت خارج شدید' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,11 +8,14 @@ import { UsersModule } from '../users/users.module';
|
||||
import { AdminModule } from '../admin/admin.module';
|
||||
import { getJwtSecret } from './auth.constants';
|
||||
|
||||
import { RedisModule } from '../redis/redis.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
forwardRef(() => AdminModule),
|
||||
PassportModule,
|
||||
RedisModule,
|
||||
JwtModule.registerAsync({
|
||||
useFactory: () => ({
|
||||
secret: getJwtSecret(),
|
||||
|
||||
@ -155,10 +155,11 @@ export class AuthService {
|
||||
const payload = { sub: user.id, phoneNumber: user.mobile };
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
|
||||
const { password: _p, ...safeUser } = user;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
user,
|
||||
user: safeUser,
|
||||
accessToken,
|
||||
},
|
||||
};
|
||||
@ -210,10 +211,11 @@ export class AuthService {
|
||||
const payload = { sub: user.id, phoneNumber: user.mobile };
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
|
||||
const { password: _p, ...safeUser } = user;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
user,
|
||||
user: safeUser,
|
||||
accessToken,
|
||||
},
|
||||
};
|
||||
@ -251,18 +253,17 @@ export class AuthService {
|
||||
const payload = { sub: user.id, phoneNumber: user.mobile };
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
|
||||
const { password: _pw, ...safeUser } = user;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
user,
|
||||
user: safeUser,
|
||||
accessToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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'
|
||||
const dbAdmin = await this.prisma.user.findFirst({
|
||||
@ -280,21 +281,45 @@ export class AuthService {
|
||||
email: dbAdmin.email,
|
||||
role: dbAdmin.role,
|
||||
};
|
||||
const { password: _p, ...safeUser } = dbAdmin;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
user: dbAdmin,
|
||||
user: safeUser,
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to validated environment variable credentials
|
||||
if (body.email === adminEmail && body.password === adminPassword) {
|
||||
// Fallback to validated environment variable credentials only if configured securely
|
||||
const adminEmail = process.env.ADMIN_EMAIL;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
|
||||
if (
|
||||
adminEmail &&
|
||||
adminPassword &&
|
||||
adminPassword.length >= 8 &&
|
||||
body.email &&
|
||||
body.password
|
||||
) {
|
||||
const emailMatches =
|
||||
body.email.length === adminEmail.length &&
|
||||
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: body.email,
|
||||
email: adminEmail,
|
||||
role: 'Admin',
|
||||
};
|
||||
return {
|
||||
@ -309,6 +334,7 @@ export class AuthService {
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException({
|
||||
message: 'ایمیل یا رمز عبور مدیریت اشتباه است',
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,9 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { getJwtSecret } from './auth.constants';
|
||||
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import type { Request } from 'express';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
@ -13,18 +16,37 @@ export interface JwtPayload {
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private readonly usersService: UsersService) {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly redisService: RedisService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: getJwtSecret(),
|
||||
passReqToCallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
// Bypass DB lookup for local admin user to prevent UUID casting errors
|
||||
async validate(req: Request, payload: JwtPayload) {
|
||||
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') {
|
||||
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);
|
||||
|
||||
@ -52,5 +52,18 @@ export function validateEnv(config: Record<string, unknown>) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -10,6 +10,8 @@ import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
interface RequestWithUser {
|
||||
user?: {
|
||||
role?: string;
|
||||
isApiKey?: boolean;
|
||||
scopes?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@ -33,11 +35,23 @@ export class RolesGuard implements CanActivate {
|
||||
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 hasRole = requiredRoles.some(
|
||||
(r) =>
|
||||
r.toLowerCase() === userRoleLower ||
|
||||
(userRoleLower.includes('admin') && r.toLowerCase().includes('admin')),
|
||||
(r) => r.toLowerCase() === userRoleLower,
|
||||
);
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('سطح دسترسی شما کافی نیست');
|
||||
|
||||
@ -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 { 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';
|
||||
|
||||
@ApiExcludeController()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin', 'SUPER_ADMIN')
|
||||
@Controller('metrics')
|
||||
export class MetricsController {
|
||||
private static requestCount = 0;
|
||||
|
||||
@ -19,18 +19,10 @@ import compression from 'compression';
|
||||
async function bootstrap() {
|
||||
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);
|
||||
|
||||
// Trust reverse proxy (Nginx / Docker ingress) to resolve real client IP from X-Forwarded-For
|
||||
app.set('trust proxy', true);
|
||||
// Trust 1 reverse proxy hop (Nginx / Cloudflare / Ingress) to prevent IP spoofing
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Serve static uploads folder with 1-year aggressive browser caching (immutable)
|
||||
const staticUploadOptions = {
|
||||
@ -54,14 +46,44 @@ async function bootstrap() {
|
||||
|
||||
app.use(
|
||||
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');
|
||||
|
||||
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({
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@ -14,6 +14,22 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
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 {
|
||||
user: {
|
||||
id?: string;
|
||||
@ -25,7 +41,36 @@ export interface UserReqPayload {
|
||||
@ApiTags('Prescriptions - نسخه و تاییدیه دارویی')
|
||||
@Controller('prescriptions')
|
||||
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()
|
||||
@ApiOperation({ summary: 'بارگذاری نسخه جدید توسط کاربر' })
|
||||
|
||||
@ -4,8 +4,10 @@ import { PrescriptionsService } from './prescriptions.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SmsService } from '../common/services/sms.service';
|
||||
|
||||
import { AdminModule } from '../admin/admin.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, AdminModule],
|
||||
controllers: [PrescriptionsController],
|
||||
providers: [PrescriptionsService, SmsService],
|
||||
exports: [PrescriptionsService],
|
||||
|
||||
@ -9,6 +9,7 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||
this.client = new Redis({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: Number(process.env.REDIS_PORT) || 16379,
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -39,6 +39,13 @@ export const DEFAULT_UI_TEXTS: Record<string, string> = {
|
||||
contact_telegram: 'https://t.me/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 ===
|
||||
shipping_notice:
|
||||
'ارسال رایگان برای سفارشهای بالای ۱۵۰ هزار تومان — گارانتی اصالت کنینا آلمان',
|
||||
|
||||
@ -4,9 +4,9 @@ services:
|
||||
container_name: canino_db_prod
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: canino_prod
|
||||
POSTGRES_PASSWORD: caninopassword_prod
|
||||
POSTGRES_DB: caninodb_prod
|
||||
POSTGRES_USER: ${POSTGRES_USER:-canino_prod}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-caninodb_prod}
|
||||
volumes:
|
||||
- canina_prod_db:/var/lib/postgresql/data
|
||||
networks:
|
||||
@ -15,6 +15,14 @@ services:
|
||||
redis_prod:
|
||||
image: redis:7-alpine
|
||||
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
|
||||
networks:
|
||||
- traefik_public
|
||||
@ -29,9 +37,14 @@ services:
|
||||
- 217.218.127.127
|
||||
- 217.218.155.155
|
||||
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_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
|
||||
volumes:
|
||||
- canina_prod_uploads:/app/uploads
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@ -137,6 +137,11 @@ export async function GET(
|
||||
responseHeaders.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000, immutable');
|
||||
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, {
|
||||
status: backendRes.status,
|
||||
statusText: backendRes.statusText,
|
||||
|
||||
@ -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) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Invalid revalidation secret token' },
|
||||
|
||||
@ -733,7 +733,7 @@ export default function CheckoutPage() {
|
||||
<div className="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
@ -98,7 +98,7 @@ export default function PrescriptionUploadModal({ isOpen, onClose }: Prescriptio
|
||||
const api = (await import("../lib/services/api")).default;
|
||||
const formData = new FormData();
|
||||
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" }
|
||||
});
|
||||
const fileUrl = uploadRes.data?.data?.url || uploadRes.data?.url || uploadRes.data?.filename || file.name;
|
||||
|
||||
@ -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>کارت: <strong>۶۲۱۹-۸۶۱۹-۷۴۰۱-۲۰۷۱</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>
|
||||
<p className="text-[10px] font-bold text-blue-800 leading-relaxed pt-1">
|
||||
پس از واریز، فیش توسط تیم پشتیبانی بررسی و تایید خواهد شد.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user