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
|
// 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 courseId = req.params.id;
|
||||||
const userId = req.user!.id;
|
const userId = req.user!.id;
|
||||||
const { billingType, creditCardInfo } = req.body;
|
const { billingType, creditCardInfo, installmentCount } = req.body;
|
||||||
|
|
||||||
const db = loadDb();
|
const db = loadDb();
|
||||||
const user = db.users.find(u => u.id === userId);
|
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;
|
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(
|
const asaasPayment = await AsaasService.createPayment(
|
||||||
customerId,
|
customerId,
|
||||||
course.price || 49.90,
|
course.price || 49.90,
|
||||||
billingType || 'PIX',
|
billingType || 'PIX',
|
||||||
`unlock_course_${courseId}`,
|
`unlock_course_${courseId}`,
|
||||||
creditCardInfo
|
processedCardInfo,
|
||||||
|
installments
|
||||||
);
|
);
|
||||||
|
|
||||||
let pixData = null;
|
let pixData = null;
|
||||||
|
|
@ -929,14 +976,14 @@ app.post('/api/admin/reorder', authenticateToken, requireAdmin, (req: Authentica
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- ASAAS WEBHOOK HANDLER ---
|
// --- 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) => {
|
app.post('/api/webhooks/asaas', (req: Request, res: Response) => {
|
||||||
// Optional token verification
|
const db = loadDb();
|
||||||
const token = req.headers['asaas-token'] || req.headers['authorization'];
|
const configuredSecret = db.settings?.asaasWebhookSecret || process.env.ASAAS_WEBHOOK_TOKEN || 'devflix_webhook_secret_key';
|
||||||
if (process.env.ASAAS_WEBHOOK_TOKEN && token !== ASAAS_WEBHOOK_TOKEN) {
|
|
||||||
res.status(401).json({ error: 'Não autorizado - Token de Webhook incorreto' });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
const [ccName, setCcName] = useState('');
|
const [ccName, setCcName] = useState('');
|
||||||
const [ccExpiry, setCcExpiry] = useState('');
|
const [ccExpiry, setCcExpiry] = useState('');
|
||||||
const [ccCvv, setCcCvv] = useState('');
|
const [ccCvv, setCcCvv] = useState('');
|
||||||
|
const [installments, setInstallments] = useState(1);
|
||||||
|
|
||||||
const coursePrice = course.price || 49.90;
|
const coursePrice = course.price || 49.90;
|
||||||
|
|
||||||
|
|
@ -76,7 +77,8 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
billingType: paymentMethod,
|
billingType: paymentMethod,
|
||||||
creditCardInfo
|
creditCardInfo,
|
||||||
|
installmentCount: paymentMethod === 'CREDIT_CARD' ? installments : 1
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -277,6 +279,25 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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(
|
public static async createPayment(
|
||||||
customerId: string,
|
customerId: string,
|
||||||
value: number,
|
value: number,
|
||||||
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO',
|
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO',
|
||||||
description: string,
|
description: string,
|
||||||
creditCardInfo?: any
|
creditCardInfo?: any,
|
||||||
|
installmentCount?: number
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const apiKey = this.getApiKey();
|
const apiKey = this.getApiKey();
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
|
|
@ -251,9 +292,18 @@ export class AsaasService {
|
||||||
description
|
description
|
||||||
};
|
};
|
||||||
|
|
||||||
if (billingType === 'CREDIT_CARD' && creditCardInfo) {
|
if (billingType === 'CREDIT_CARD') {
|
||||||
payload.creditCard = creditCardInfo.creditCard;
|
if (creditCardInfo?.creditCardToken) {
|
||||||
payload.creditCardHolderInfo = creditCardInfo.creditCardHolderInfo;
|
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`, {
|
const response = await fetch(`${this.getApiUrl()}/payments`, {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue