resetea.net/api/routes/gmail_oauth.js

160 lines
5.7 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const { google } = require('googleapis');
const { buildLetterText, PROVIDER_DATA } = require('../services/mailer');
const ALLOWED_PROVIDERS = new Set(Object.keys(PROVIDER_DATA));
const STATE_MAX_BYTES = 4096;
const SALT = () => process.env.SALT || 'resetea-oauth-default-insecure';
/* ── Elimina CRLF y tabuladores — previene header injection en RFC 2822 ── */
function sanitizeHeader(str, maxLen = 300) {
return String(str || '').replace(/[\r\n\t\x00-\x1F\x7F]/g, ' ').trim().slice(0, maxLen);
}
/* ── Firma HMAC-SHA256 del state — previene state forgery en OAuth callback ── */
function signState(payload) {
const nonce = crypto.randomBytes(16).toString('hex');
const data = { ...payload, _nonce: nonce };
const sig = crypto.createHmac('sha256', SALT())
.update(JSON.stringify(data))
.digest('hex');
return Buffer.from(JSON.stringify({ ...data, _sig: sig })).toString('base64url');
}
function verifyState(raw) {
if (!raw || typeof raw !== 'string') throw new Error('State ausente');
if (Buffer.byteLength(raw, 'utf8') > STATE_MAX_BYTES) throw new Error('State demasiado grande');
let obj;
try { obj = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); }
catch { throw new Error('State malformado'); }
const { _sig, ...data } = obj;
if (typeof _sig !== 'string' || _sig.length !== 64) throw new Error('Firma ausente o malformada');
const expected = crypto.createHmac('sha256', SALT()).update(JSON.stringify(data)).digest('hex');
const sigBuf = Buffer.from(_sig, 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) throw new Error('Firma inválida');
/* Devuelve payload sin los campos internos */
const { _nonce, ...params } = data;
return params;
}
function getOAuth2Client() {
return new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI || 'https://resetea.net/api/gmail/callback'
);
}
/* ── GET /api/gmail/auth ─────────────────────────────────────────── */
exports.authInit = (req, res) => {
const { provider, name, email, nickname, phone, address, extra, requestType } = req.query;
if (!provider || !email || !name)
return res.status(400).json({ error: 'Faltan parámetros obligatorios (provider, name, email).' });
if (!ALLOWED_PROVIDERS.has(provider))
return res.status(400).json({ error: 'Proveedor no soportado.' });
/* Sanitizamos los campos que luego irán a cabeceras de email */
const state = signState({
provider,
name: sanitizeHeader(name, 200),
email: sanitizeHeader(email, 200),
nickname: sanitizeHeader(nickname, 100),
phone: sanitizeHeader(phone, 30),
address: sanitizeHeader(address, 300),
extra: sanitizeHeader(extra, 500),
requestType: sanitizeHeader(requestType, 20),
});
const oauth2Client = getOAuth2Client();
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'online',
scope: ['https://www.googleapis.com/auth/gmail.send'],
prompt: 'consent',
state,
});
res.redirect(authUrl);
};
/* ── GET /api/gmail/callback ─────────────────────────────────────── */
exports.authCallback = async (req, res) => {
const { code, state, error } = req.query;
if (error) return res.redirect('/plantillas.html?oauth=cancelled');
if (!code || !state)
return res.status(400).send('Parámetros OAuth inválidos.');
let params;
try {
params = verifyState(state);
} catch (e) {
console.warn('OAuth state rejection:', e.message);
return res.status(400).send('State OAuth inválido o expirado.');
}
/* Validar provider contra whitelist (por si el state fue manipulado antes de que
implementáramos la firma, o si la firma falla silenciosamente en el futuro) */
if (!ALLOWED_PROVIDERS.has(params.provider)) {
return res.status(400).send('Proveedor no soportado.');
}
try {
const oauth2Client = getOAuth2Client();
const { tokens } = await oauth2Client.getToken(code);
oauth2Client.setCredentials(tokens);
const gmail = google.gmail({ version: 'v1', auth: oauth2Client });
const providerInfo = PROVIDER_DATA[params.provider];
if (!providerInfo.email) {
return res.redirect(
`/plantillas.html?oauth=no_email&provider=${encodeURIComponent(params.provider)}` +
`&formUrl=${encodeURIComponent(providerInfo.formUrl || '')}`
);
}
const letterText = buildLetterText({
providerInfo,
senderName: params.name,
senderEmail: params.email,
senderNick: params.nickname || '',
senderPhone: params.phone || '',
senderAddress: params.address || '',
extra: params.extra || '',
});
/* Cabeceras RFC 2822 — name y email ya vienen sanitizados desde authInit */
const subject = `Ejercicio derecho de supresión (RGPD Art. 17) — ${providerInfo.name}`;
const rawEmail = [
`From: ${params.name} <${params.email}>`,
`To: ${providerInfo.email}`,
`Subject: ${subject}`,
`MIME-Version: 1.0`,
`Content-Type: text/plain; charset=UTF-8`,
'',
letterText,
].join('\r\n');
await gmail.users.messages.send({
userId: 'me',
requestBody: { raw: Buffer.from(rawEmail).toString('base64url') },
});
res.redirect(`/plantillas.html?oauth=ok&provider=${encodeURIComponent(providerInfo.name)}`);
} catch (err) {
console.error('Gmail OAuth error:', err.message);
res.redirect('/plantillas.html?oauth=error');
}
};