feat: credit card tokenization, strict 401 webhook security, rate limiting, and course installments selection
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 32s Details

This commit is contained in:
Sidney Gomes 2026-07-21 17:18:18 -03:00
parent 42f33a049f
commit f9657220b3
3 changed files with 134 additions and 16 deletions

View File

@ -285,11 +285,36 @@ app.get('/api/courses/:id', authenticateToken, (req: AuthenticatedRequest, res:
});
});
// Rate limiting protection for payment checkout endpoints
const checkoutRateLimitMap = new Map<string, { count: number, resetAt: number }>();
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;
}

View File

@ -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 }:
/>
</div>
</div>
{/* Parcelamento (Installments Dropdown) */}
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Opções de Parcelamento</label>
<select
value={installments}
onChange={(e) => setInstallments(Number(e.target.value))}
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none cursor-pointer bg-zinc-900"
>
{[1, 2, 3, 4, 5, 6, 10, 12].map(n => {
const val = (coursePrice / n).toFixed(2);
return (
<option key={n} value={n} className="bg-zinc-900 text-white">
{n}x de R$ {val} {n === 1 ? '(À vista sem juros)' : '(sem juros)'}
</option>
);
})}
</select>
</div>
</div>
)}

View File

@ -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<any> {
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`, {