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
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 32s
Details
This commit is contained in:
parent
42f33a049f
commit
f9657220b3
67
server.ts
67
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<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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,11 +292,20 @@ export class AsaasService {
|
|||
description
|
||||
};
|
||||
|
||||
if (billingType === 'CREDIT_CARD' && creditCardInfo) {
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue