From f9657220b33c0dcbf2cc3892d344b0169848fb4e Mon Sep 17 00:00:00 2001 From: Sidney Gomes Date: Tue, 21 Jul 2026 17:18:18 -0300 Subject: [PATCH] feat: credit card tokenization, strict 401 webhook security, rate limiting, and course installments selection --- server.ts | 67 +++++++++++++++++++++++----- src/components/CourseUnlockModal.tsx | 23 +++++++++- src/services/asaas.ts | 60 ++++++++++++++++++++++--- 3 files changed, 134 insertions(+), 16 deletions(-) diff --git a/server.ts b/server.ts index 614f330..d10f5ca 100644 --- a/server.ts +++ b/server.ts @@ -285,11 +285,36 @@ app.get('/api/courses/:id', authenticateToken, (req: AuthenticatedRequest, res: }); }); +// Rate limiting protection for payment checkout endpoints +const checkoutRateLimitMap = new Map(); + +const checkoutRateLimiter = (req: Request, res: Response, next: Function) => { + const ip = req.ip || req.socket.remoteAddress || 'unknown'; + const now = Date.now(); + const record = checkoutRateLimitMap.get(ip) || { count: 0, resetAt: now + 60000 }; + + if (now > record.resetAt) { + record.count = 1; + record.resetAt = now + 60000; + } else { + record.count += 1; + } + + checkoutRateLimitMap.set(ip, record); + + if (record.count > 15) { + res.status(429).json({ error: 'Muitas tentativas de pagamento num curto período. Aguarde um minuto e tente novamente.' }); + return; + } + + next(); +}; + // 5.5 Unlock Course -app.post('/api/courses/:id/unlock', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { +app.post('/api/courses/:id/unlock', authenticateToken, checkoutRateLimiter, async (req: AuthenticatedRequest, res: Response) => { const courseId = req.params.id; const userId = req.user!.id; - const { billingType, creditCardInfo } = req.body; + const { billingType, creditCardInfo, installmentCount } = req.body; const db = loadDb(); const user = db.users.find(u => u.id === userId); @@ -311,12 +336,34 @@ app.post('/api/courses/:id/unlock', authenticateToken, async (req: Authenticated user.asaasCustomerId = customerId; } + let processedCardInfo = creditCardInfo; + + // Tokenize credit card if raw info is provided (PCI-DSS Compliance) + if (billingType === 'CREDIT_CARD' && creditCardInfo?.creditCard?.number) { + try { + const tokenResult = await AsaasService.tokenizeCreditCard( + customerId, + creditCardInfo.creditCard, + creditCardInfo.creditCardHolderInfo + ); + processedCardInfo = { + creditCardToken: tokenResult.creditCardToken, + creditCardNumberLast4: tokenResult.creditCardNumber + }; + } catch (tokErr) { + console.warn('Tokenization fallback to direct API transmission:', tokErr); + } + } + + const installments = installmentCount ? Number(installmentCount) : 1; + const asaasPayment = await AsaasService.createPayment( customerId, course.price || 49.90, billingType || 'PIX', `unlock_course_${courseId}`, - creditCardInfo + processedCardInfo, + installments ); let pixData = null; @@ -929,14 +976,14 @@ app.post('/api/admin/reorder', authenticateToken, requireAdmin, (req: Authentica }); // --- ASAAS WEBHOOK HANDLER --- -// Standard webhook validation token, can be verified in Production -const ASAAS_WEBHOOK_TOKEN = process.env.ASAAS_WEBHOOK_TOKEN || 'devflix_webhook_secret_key'; - app.post('/api/webhooks/asaas', (req: Request, res: Response) => { - // Optional token verification - const token = req.headers['asaas-token'] || req.headers['authorization']; - if (process.env.ASAAS_WEBHOOK_TOKEN && token !== ASAAS_WEBHOOK_TOKEN) { - res.status(401).json({ error: 'Não autorizado - Token de Webhook incorreto' }); + const db = loadDb(); + const configuredSecret = db.settings?.asaasWebhookSecret || process.env.ASAAS_WEBHOOK_TOKEN || 'devflix_webhook_secret_key'; + + const tokenHeader = req.headers['asaas-token'] || req.headers['authorization']; + if (configuredSecret && tokenHeader !== configuredSecret) { + console.warn(`[WEBHOOK SEGURANÇA] Token inválido bloqueado: ${tokenHeader}`); + res.status(401).json({ error: 'Não autorizado - Token de Webhook do Asaas é inválido ou ausente.' }); return; } diff --git a/src/components/CourseUnlockModal.tsx b/src/components/CourseUnlockModal.tsx index a84e40d..c6b60ac 100644 --- a/src/components/CourseUnlockModal.tsx +++ b/src/components/CourseUnlockModal.tsx @@ -24,6 +24,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }: const [ccName, setCcName] = useState(''); const [ccExpiry, setCcExpiry] = useState(''); const [ccCvv, setCcCvv] = useState(''); + const [installments, setInstallments] = useState(1); const coursePrice = course.price || 49.90; @@ -76,7 +77,8 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }: }, body: JSON.stringify({ billingType: paymentMethod, - creditCardInfo + creditCardInfo, + installmentCount: paymentMethod === 'CREDIT_CARD' ? installments : 1 }) }); @@ -277,6 +279,25 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }: /> + + {/* Parcelamento (Installments Dropdown) */} +
+ + +
)} diff --git a/src/services/asaas.ts b/src/services/asaas.ts index b6cfffe..2bba5ce 100644 --- a/src/services/asaas.ts +++ b/src/services/asaas.ts @@ -219,14 +219,55 @@ export class AsaasService { } /** - * Create a single payment in Asaas + * Tokenize a credit card directly with Asaas + */ + public static async tokenizeCreditCard( + customerId: string, + creditCard: any, + creditCardHolderInfo: any + ): Promise<{ creditCardToken: string; creditCardNumber: string; creditCardBrand: string }> { + const apiKey = this.getApiKey(); + if (!apiKey) { + return { + creditCardToken: `tok_sim_${Math.random().toString(36).substring(2, 9)}`, + creditCardNumber: creditCard?.number ? creditCard.number.slice(-4) : '4321', + creditCardBrand: 'MASTERCARD' + }; + } + + try { + const response = await fetch(`${this.getApiUrl()}/creditCard/tokenize`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ + customer: customerId, + creditCard, + creditCardHolderInfo + }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.errors?.[0]?.description || 'Falha ao tokenizar cartão no Asaas'); + } + + return await response.json(); + } catch (err: any) { + console.error('Asaas tokenizeCreditCard error:', err); + throw err; + } + } + + /** + * Create a single payment in Asaas (supports installments and tokenized card) */ public static async createPayment( customerId: string, value: number, billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO', description: string, - creditCardInfo?: any + creditCardInfo?: any, + installmentCount?: number ): Promise { const apiKey = this.getApiKey(); if (!apiKey) { @@ -251,9 +292,18 @@ export class AsaasService { description }; - if (billingType === 'CREDIT_CARD' && creditCardInfo) { - payload.creditCard = creditCardInfo.creditCard; - payload.creditCardHolderInfo = creditCardInfo.creditCardHolderInfo; + if (billingType === 'CREDIT_CARD') { + if (creditCardInfo?.creditCardToken) { + payload.creditCardToken = creditCardInfo.creditCardToken; + } else if (creditCardInfo) { + payload.creditCard = creditCardInfo.creditCard; + payload.creditCardHolderInfo = creditCardInfo.creditCardHolderInfo; + } + + if (installmentCount && installmentCount > 1) { + payload.installmentCount = installmentCount; + payload.installmentValue = Number((value / installmentCount).toFixed(2)); + } } const response = await fetch(`${this.getApiUrl()}/payments`, {