160 lines
5.7 KiB
JavaScript
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');
|
|
}
|
|
};
|