refactor: implement centralized auth middleware and standardize cross-subdomain session management

This commit is contained in:
Giuseppe Raffa
2026-04-21 22:17:48 +02:00
parent 69012029ad
commit 924c2b5367
22 changed files with 670 additions and 530 deletions

View File

@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const parser = require('cookie-parser'); const parser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { requireAuth } = require('./middlewares/auth');
const app = express(); const app = express();
const PORT = process.env.PORT; const PORT = process.env.PORT;
@@ -56,33 +57,8 @@ app.get('/health', async (req, res) => {
const paramsSensorRoutes = require('./routes/params.sensor'); const paramsSensorRoutes = require('./routes/params.sensor');
app.use('/params/sensor', paramsSensorRoutes); app.use('/params/sensor', paramsSensorRoutes);
// Middleware di autenticazione per le API // Middleware di autenticazione per tutte le API protette
app.use((req, res, next) => { app.use(requireAuth);
if (req.path === '/health' || req.path === '/') return next();
// 1. Service-to-service: x-api-key header
const apiKey = req.headers['x-api-key'];
if (apiKey && apiKey === process.env.INTERNAL_API_KEY) {
req.internal = true;
return next();
}
// 2. User auth: cookie o Authorization header
const token = req.cookies?.auth_token
|| (req.headers.authorization?.startsWith('Bearer ') && req.headers.authorization.slice(7));
if (!token) {
return res.status(401).json({ error: 'Unauthorized: Nessun token di autenticazione fornito' });
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ error: 'Unauthorized: Token non valido o scaduto' });
}
});
const dataRoutes = require('./routes/data'); const dataRoutes = require('./routes/data');
app.use('/data', dataRoutes); app.use('/data', dataRoutes);

View File

@@ -0,0 +1,70 @@
/**
* Middleware di autenticazione per API REST.
* Supporta tre modalità:
* - x-api-key (service-to-service, INTERNAL_API_KEY)
* - cookie auth_token (utenti loggati dal browser, SSO via .mebboat.it)
* - Authorization: Bearer <jwt>
*
* Il JWT viene firmato da auth.mebboat.it con JWT_SECRET e verificato localmente.
* Il cookie è condiviso tra i sottodomini grazie a domain=.mebboat.it
*/
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
function extractToken(req) {
const header = req.headers.authorization;
const bearer = header && header.startsWith('Bearer ') ? header.slice(7) : null;
return (req.cookies && req.cookies.auth_token) || bearer || null;
}
function verifyToken(token) {
if (!token || typeof token !== 'string' || token.length > 2048) return null;
try {
const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
return {
user_id: p.sub,
username: p.username,
session_id: p.session_id,
iat: p.iat,
exp: p.exp
};
} catch {
return null;
}
}
/**
* Accetta utente loggato (cookie/bearer) o chiamata interna (x-api-key).
* Imposta req.user con i dati dell'utente, oppure req.internal = true.
*/
function requireAuth(req, res, next) {
// 1. Service-to-service
const apiKey = req.headers['x-api-key'];
if (apiKey && INTERNAL_KEY && apiKey === INTERNAL_KEY) {
req.internal = true;
return next();
}
// 2. User auth (cookie o Bearer)
const user = verifyToken(extractToken(req));
if (!user) return res.status(401).json({ error: 'unauthorized' });
req.user = user;
next();
}
/**
* Solo service-to-service (x-api-key).
*/
function requireInternal(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!INTERNAL_KEY || !apiKey || apiKey !== INTERNAL_KEY) {
return res.status(403).json({ error: 'forbidden' });
}
req.internal = true;
next();
}
module.exports = { requireAuth, requireInternal, verifyToken, extractToken };

View File

@@ -1,101 +1,166 @@
const query = require('../storage/database').query;
const track = require('../tools/tracking');
const { v4: uuid } = require('uuid'); const { v4: uuid } = require('uuid');
const { query } = require('../storage/database');
const security = require('../tools/security'); const security = require('../tools/security');
const tracking = require('../tools/tracking');
async function register(username, password) { // ─── ERRORI CUSTOM ──────────────────────────────────────────────────
const userExists = await query('SELECT id FROM users WHERE username = $1', [username]);
if (userExists.rows.length > 0) { class AuthError extends Error {
throw new Error('User already exists'); constructor(code, message) {
super(message || code);
this.code = code;
} }
const hashedPassword = await security.hashPassword(password);
const id = uuid();
await query(
'INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)',
[id, username, hashedPassword]
);
return { success: true, user: { id, username } };
} }
// ─── REGISTRAZIONE ──────────────────────────────────────────────────
async function register(username, password) {
const exists = await query('SELECT id FROM users WHERE username = $1', [username]);
if (exists.rows.length) throw new AuthError('USER_EXISTS', 'Username già in uso');
const hash = await security.hashPassword(password);
const id = uuid();
await query(
'INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)',
[id, username, hash]
);
return { id, username };
}
// ─── LOGIN ──────────────────────────────────────────────────────────
async function login(username, password) { async function login(username, password) {
const result = await query( const { rows } = await query(
'SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1', 'SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1',
[username] [username]
); );
if (!rows.length) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide');
if (result.rows.length === 0) { const user = rows[0];
throw new Error('No user matched'); if (!user.is_active) throw new AuthError('ACCOUNT_INACTIVE', 'Account disattivato');
}
const user = result.rows[0]; const ok = await security.verifyPassword(password, user.password_hash);
if (!ok) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide');
if (!user.is_active) { return { id: user.id, username: user.username, created_at: user.created_at };
throw new Error('User account is not active');
}
const isValid = await security.verifyPassword(password, user.password_hash);
if (!isValid) {
throw new Error('Password mismatch');
}
return {
id: user.id,
username: user.username,
created: user.created_at
};
} }
async function logout(sessionID) { // ─── SESSIONI ───────────────────────────────────────────────────────
if (!sessionID) {
throw new Error('No session ID provided');
}
const result = await query('UPDATE sessions SET is_revoked = TRUE WHERE id = $1', [sessionID]); async function createSession(userId, userAgent, ip) {
return result.rowCount > 0;
}
async function newSession(userId, userAgent, ip) {
const id = uuid(); const id = uuid();
const sessionCode = security.generateSessionCode(); const code = security.sessionCode();
const metadata = track.getBasicMetadata(userAgent); const meta = tracking.extract(userAgent);
await query( await query(
`INSERT INTO sessions (id, user_id, session_code, encoded_username, ip_address, user_agent, browser, os, device_type) `INSERT INTO sessions
(id, user_id, session_code, encoded_username, ip_address, user_agent, browser, os, device_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[id, userId, sessionCode, '', ip, userAgent, metadata.browser, metadata.os, metadata.device_type] [id, userId, code, '', ip, userAgent, meta.browser, meta.os, meta.device_type]
); );
return { id, code };
return { id, sessionCode };
} }
async function validateSession(sessionId) { async function validateSession(sessionId) {
if (!sessionId || typeof sessionId !== 'string') { if (!sessionId || typeof sessionId !== 'string') {
throw new Error('Invalid session ID'); throw new AuthError('INVALID_SESSION', 'Sessione non valida');
} }
const result = await query( const { rows } = await query(
'SELECT s.id, u.is_active FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.id = $1 AND s.is_revoked = FALSE', `SELECT s.id, s.is_revoked, u.is_active
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = $1`,
[sessionId] [sessionId]
); );
if (result.rows.length === 0) { if (!rows.length) throw new AuthError('INVALID_SESSION', 'Sessione non trovata');
throw new Error('Session not found or revoked'); if (rows[0].is_revoked) throw new AuthError('SESSION_REVOKED', 'Sessione revocata');
} if (!rows[0].is_active) throw new AuthError('ACCOUNT_INACTIVE', 'Account disattivato');
if (!result.rows[0].is_active) { // Aggiorna last_active in modo non bloccante
throw new Error('User account is not active'); query('UPDATE sessions SET last_active = NOW() WHERE id = $1', [sessionId]).catch(() => {});
return true;
}
async function revokeSession(sessionId, userId) {
if (userId) {
const r = await query(
'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND user_id = $2 AND is_revoked = FALSE',
[sessionId, userId]
);
return r.rowCount > 0;
} }
const r = await query(
'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND is_revoked = FALSE',
[sessionId]
);
return r.rowCount > 0;
}
async function listSessions(userId) {
const { rows } = await query(
`SELECT id, ip_address, browser, os, device_type,
location_country, location_city, created_at, last_active, is_revoked
FROM sessions
WHERE user_id = $1
ORDER BY last_active DESC`,
[userId]
);
return rows;
}
// ─── LOOKUP UTENTE ──────────────────────────────────────────────────
async function getUserById(userId) {
const { rows } = await query(
'SELECT id, username, is_active, created_at, telegram_id FROM users WHERE id = $1',
[userId]
);
return rows[0] || null;
}
async function getAllUsers() {
const { rows } = await query(
'SELECT id, username, is_active, created_at, telegram_id FROM users'
);
return rows;
}
async function getUsersToNotify() {
const { rows } = await query(
'SELECT telegram_id FROM users WHERE telegram_id IS NOT NULL'
);
return rows;
}
async function updateUsername(userId, newUsername) {
const r = await query(
'UPDATE users SET username = $1 WHERE id = $2 RETURNING username',
[newUsername, userId]
);
return r.rowCount > 0 ? r.rows[0] : null;
}
async function updateTelegram(userId, telegramId) {
await query(
'UPDATE users SET telegram_id = $1 WHERE id = $2',
[telegramId, userId]
);
} }
module.exports = { module.exports = {
AuthError,
register, register,
login, login,
logout, createSession,
newSession, validateSession,
validateSession revokeSession,
listSessions,
getUserById,
getAllUsers,
getUsersToNotify,
updateUsername,
updateTelegram
}; };

View File

@@ -1,56 +0,0 @@
const query = require('../storage/database').query;
const { parseSessionToken } = require('../tools/security');
async function getSessions(username) {
const result = await query('SELECT s.id, s.session_code, s.browser, s.os, s.device_type, s.created_at, s.last_active, s.is_revoked FROM sessions s JOIN users u ON s.user_id = u.id WHERE u.username = $1 AND s.is_revoked = FALSE ORDER BY s.last_active DESC', [username]);
return result.rows.map(s => ({
id: s.id,
code: s.session_code,
browser: s.browser,
os: s.os,
deviceType: s.device_type,
createdAt: s.created_at?.toLocaleDateString('it-IT', {
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit'
}),
lastActive: s.last_active?.toLocaleDateString('it-IT', {
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit'
}),
isRevoked: s.is_revoked,
isCurrent: false
}));
};
async function getCurrentSessionID(token) {
const parsed = parseSessionToken(token);
if (!parsed) {
throw new Error('Invalid token');
}
const result = await query('SELECT id FROM sessions WHERE session_code = $1', [parsed]);
return result.rows[0]?.id || null;
}
async function revoke(id, username) {
const result = await query('UPDATE sessions s SET is_revoked = TRUE FROM users u WHERE s.id = $1 AND s.user_id = u.id AND u.username = $2', [id, username]);
return result.rowCount > 0;
}
async function revokeOthers(username, current) {
const result = await query('UPDATE sessions s SET is_revoked = TRUE FROM users u WHERE s.user_id = u.id AND u.username = $1 AND s.id != $2 AND s.is_revoked = FALSE', [username, current]);
return result.rowCount;
}
async function getCount(username) {
const result = await query('SELECT COUNT(*) as count FROM sessions s JOIN users u ON s.user_id = u.id WHERE u.username = $1 AND s.is_revoked = FALSE', [username]);
return parseInt(result.rows[0].count, 10);
}
module.exports = {
getSessions,
getCurrentSessionID,
revoke,
revokeOthers,
getCount
};

View File

@@ -116,14 +116,11 @@ app.use('/api/sessions', require('./routes/sessions'));
// ─── HEALTH CHECK ─────────────────────────────────────────────────── // ─── HEALTH CHECK ───────────────────────────────────────────────────
app.get('/health', async (req, res) => { app.get('/health', async (req, res) => {
const dbConnected = await database.checkPostgres(); const dbConnected = await database.checkPostgres();
const redisHelper = require('./storage/redis');
const redisConnected = await redisHelper.checkRedis();
res.json({ res.json({
status: dbConnected && redisConnected ? "ok" : "degraded", status: dbConnected ? "ok" : "degraded",
service: "auth", service: "auth",
database: dbConnected ? "connected" : "disconnected", database: dbConnected ? "connected" : "disconnected",
redis: redisConnected ? "connected" : "disconnected",
version: version, version: version,
build_number: vBuild, build_number: vBuild,
version_state: vState version_state: vState

View File

@@ -3,40 +3,30 @@ const crypto = require('crypto');
const API_KEY = process.env.INTERNAL_API_KEY; const API_KEY = process.env.INTERNAL_API_KEY;
/** /**
* Middleware di autenticazione per servizi interni (container-to-container). * Middleware: autentica chiamate service-to-service tramite header x-internal-api-key.
* Verifica l'header 'x-internal-api-key' contro INTERNAL_API_KEY nell'env. * Usa timing-safe comparison per prevenire timing attacks.
*
* SICUREZZA:
* - Se INTERNAL_API_KEY non è configurata, TUTTE le richieste vengono rifiutate
* - Usa timingSafeEqual per prevenire attacchi timing side-channel
*/ */
const internalAuth = (req, res, next) => { module.exports = function internalAuth(req, res, next) {
// Se la chiave non è configurata nel server, blocca tutto
if (!API_KEY) { if (!API_KEY) {
console.error('[SECURITY] INTERNAL_API_KEY absent! All internal requests blocked.'); console.error('[SECURITY] INTERNAL_API_KEY mancante, blocco tutte le richieste interne.');
return res.status(503).json({ error: 'Service not configured correctly' }); return res.status(503).json({ error: 'service_not_configured' });
} }
const internalToken = req.headers['x-internal-api-key']; const token = req.headers['x-internal-api-key'];
if (!token || typeof token !== 'string') {
if (!internalToken || typeof internalToken !== 'string') { return res.status(403).json({ error: 'forbidden' });
return res.status(403).json({ error: 'unauthorized' });
} }
// Confronto timing-safe per prevenire timing attacks
try { try {
const tokenBuffer = Buffer.from(internalToken, 'utf8'); const a = Buffer.from(token, 'utf8');
const keyBuffer = Buffer.from(API_KEY, 'utf8'); const b = Buffer.from(API_KEY, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
if (tokenBuffer.length !== keyBuffer.length || !crypto.timingSafeEqual(tokenBuffer, keyBuffer)) { return res.status(403).json({ error: 'forbidden' });
return res.status(403).json({ error: 'Accesso negato' });
} }
} catch { } catch {
return res.status(403).json({ error: 'Accesso negato' }); return res.status(403).json({ error: 'forbidden' });
} }
req.user = { id: 'system', role: 'internal_service' }; req.internal = true;
return next(); next();
}; };
module.exports = internalAuth;

View File

@@ -1,34 +1,37 @@
const jwt = require('../tools/jwt'); const jwt = require('../tools/jwt');
const { validateSession } = require('../core/auth.core'); const { validateSession } = require('../core/auth.core');
const userAuth = async (req, res, next) => { /**
const token = (req.cookies && req.cookies.auth_token) || jwt.getToken(req.headers['authorization']); * Middleware: richiede un utente autenticato valido.
* Legge il token dal cookie `auth_token` o dall'header `Authorization: Bearer <token>`.
*
* Se la richiesta accetta HTML → redirect a /login con redirect-back URL.
* Altrimenti → 401 JSON.
*/
module.exports = async function userAuth(req, res, next) {
const token = (req.cookies && req.cookies.auth_token) || jwt.bearer(req.headers.authorization);
const unauthorized = (reason) => { const unauthorized = (reason) => {
if (req.accepts('html')) { if (req.accepts('html') && !req.xhr) {
const redirect = encodeURIComponent(req.originalUrl); const r = encodeURIComponent(req.originalUrl);
return res.redirect(`/login?redirect=${redirect}`); return res.redirect(`/login?redirect=${r}`);
} }
return res.status(401).json({ error: reason || 'Non autorizzato' }); return res.status(401).json({ error: reason || 'unauthorized' });
}; };
if (!token || typeof token !== 'string' || token.length > 2048) { if (!token || typeof token !== 'string' || token.length > 2048) {
return unauthorized('Token mancante o non valido'); return unauthorized('missing_token');
} }
const verified = jwt.verifyToken(token); const v = jwt.verify(token);
if (!verified.valid) { if (!v.valid) return unauthorized(`token_${v.reason}`);
return unauthorized(`Sessione non valida (${verified.reason})`);
}
try { try {
await validateSession(verified.payload.session_id); await validateSession(v.payload.session_id);
} catch (err) { } catch (err) {
return unauthorized('Sessione non valida o revocata'); return unauthorized(err.code || 'session_invalid');
} }
req.user = verified.payload; req.user = v.payload;
next(); next();
}; };
module.exports = userAuth;

View File

@@ -4,146 +4,175 @@ const jwt = require('../tools/jwt');
const CONSOLE_URL = process.env.CONSOLE_URL || 'http://localhost:3004'; const CONSOLE_URL = process.env.CONSOLE_URL || 'http://localhost:3004';
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined; const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
const IS_PROD = process.env.NODE_ENV === 'production';
const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/; const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/;
const PASSWORD_MIN_LENGTH = 8; const MIN_PASSWORD = 8;
const PASSWORD_MAX_LENGTH = 128; const MAX_PASSWORD = 128;
const TOKEN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 giorni
const ERROR_RESPONSES = {
csrf: { success: false, error: 'csrf', message: 'Richiesta non valida, riprova' },
invalid_credentials: { success: false, error: 'invalid_credentials', message: 'Credenziali non valide' },
internal: { success: false, error: 'internal', message: 'Errore interno, riprova più tardi' }
};
/** /**
* Restituisce un redirect sicuro, scartando URL che puntano ad API * Opzioni cookie condivise per auth_token.
* o ad host diversi da CONSOLE_URL. * Domain = `.mebboat.it` in produzione → SSO cross-subdomain
* (console.mebboat.it, ml.mebboat.it, api.mebboat.it, ecc.)
*/
function authCookieOptions(withMaxAge = true) {
const opts = {
httpOnly: true,
secure: IS_PROD,
sameSite: 'lax',
path: '/'
};
if (withMaxAge) opts.maxAge = TOKEN_MAX_AGE_MS;
if (COOKIE_DOMAIN) opts.domain = COOKIE_DOMAIN;
return opts;
}
/**
* Valida un redirect URL per prevenire open-redirect.
* Accetta solo lo stesso dominio di CONSOLE_URL (o sottodomini di COOKIE_DOMAIN).
*/ */
function resolveSafeRedirect(redirect) { function resolveSafeRedirect(redirect) {
if (!redirect || typeof redirect !== 'string') return CONSOLE_URL; if (!redirect || typeof redirect !== 'string') return CONSOLE_URL;
try { try {
const redirectUrl = new URL(redirect); const target = new URL(redirect);
const consoleUrl = new URL(CONSOLE_URL); const console_ = new URL(CONSOLE_URL);
const sameHost = redirectUrl.hostname === consoleUrl.hostname; const sameHost = target.hostname === console_.hostname;
const notApi = !redirectUrl.pathname.startsWith('/api/'); const sameApex = COOKIE_DOMAIN
? target.hostname.endsWith(COOKIE_DOMAIN.replace(/^\./, ''))
: false;
const notApi = !target.pathname.startsWith('/api/');
if (sameHost && notApi) return redirect; if ((sameHost || sameApex) && notApi) return redirect;
} catch { } catch {
// URL non valido / relativo: fallback a CONSOLE_URL // URL invalido / relativo: fallback
} }
return CONSOLE_URL; return CONSOLE_URL;
} }
// ─── POST /register ────────────────────────────────────────────────
router.post('/register', async (req, res) => { router.post('/register', async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body || {};
if (!username || !password || typeof username !== 'string' || typeof password !== 'string') { if (!username || !password || typeof username !== 'string' || typeof password !== 'string') {
return res.status(400).json({ success: false, error: 'Username e password richiesti' }); return res.status(400).json({ success: false, error: 'username_and_password_required' });
} }
if (!USERNAME_REGEX.test(username)) { if (!USERNAME_REGEX.test(username)) {
return res.status(400).json({ return res.status(400).json({ success: false, error: 'invalid_username' });
success: false,
error: 'Username non valido. 3-50 caratteri alfanumerici, underscore, punto o trattino.'
});
} }
if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD) {
if (password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH) { return res.status(400).json({ success: false, error: 'invalid_password_length' });
return res.status(400).json({
success: false,
error: `Password deve essere tra ${PASSWORD_MIN_LENGTH} e ${PASSWORD_MAX_LENGTH} caratteri`
});
} }
try { try {
await auth.register(username, password); const user = await auth.register(username, password);
return res.status(201).json({ success: true }); return res.status(201).json({ success: true, user });
} catch (err) { } catch (err) {
if (err.message === 'User already exists') { if (err.code === 'USER_EXISTS') {
return res.status(409).json({ success: false, error: 'User already exists' }); return res.status(409).json({ success: false, error: 'user_exists' });
} }
console.error('[AUTH] Register failed:', err.message); console.error('[AUTH] register:', err.message);
return res.status(500).json({ success: false, error: 'Errore interno' }); return res.status(500).json({ success: false, error: 'internal' });
} }
}); });
router.post('/login', async (req, res) => { // ─── POST /login ───────────────────────────────────────────────────
const { username, password, redirect, _csrf } = req.body;
router.post('/login', async (req, res) => {
const { username, password, redirect, _csrf } = req.body || {};
// Verifica CSRF token
const csrfCookie = req.cookies && req.cookies._csrf; const csrfCookie = req.cookies && req.cookies._csrf;
if (!_csrf || !csrfCookie || _csrf !== csrfCookie) { if (!_csrf || !csrfCookie || _csrf !== csrfCookie) {
return res.status(400).json(ERROR_RESPONSES.csrf); return res.status(400).json({
success: false, error: 'csrf', message: 'Richiesta non valida, riprova'
});
} }
if (!username || !password || typeof username !== 'string' || typeof password !== 'string') { if (!username || !password || typeof username !== 'string' || typeof password !== 'string'
return res.status(400).json(ERROR_RESPONSES.invalid_credentials); || username.length > 50 || password.length > MAX_PASSWORD) {
} return res.status(400).json({
success: false, error: 'invalid_credentials', message: 'Credenziali non valide'
if (username.length > 50 || password.length > PASSWORD_MAX_LENGTH) { });
return res.status(400).json(ERROR_RESPONSES.invalid_credentials);
} }
const safeRedirect = resolveSafeRedirect(redirect); const safeRedirect = resolveSafeRedirect(redirect);
try { try {
const user = await auth.login(username, password); const user = await auth.login(username, password);
const session = await auth.newSession(user.id, req.headers['user-agent'], req.ip); const session = await auth.createSession(user.id, req.headers['user-agent'], req.ip);
const token = jwt.generateToken(user, session.id); const token = jwt.sign(user, session.id);
const cookieOptions = { // Imposta il cookie auth_token (condiviso tra sottodomini se COOKIE_DOMAIN è impostato)
httpOnly: true, res.cookie('auth_token', token, authCookieOptions(true));
secure: process.env.NODE_ENV === 'production', // Rimuove il cookie CSRF
sameSite: 'lax', res.clearCookie('_csrf', { httpOnly: true, sameSite: 'strict', path: '/' });
maxAge: 7 * 24 * 60 * 60 * 1000
};
if (COOKIE_DOMAIN) cookieOptions.domain = COOKIE_DOMAIN;
res.cookie('auth_token', token, cookieOptions);
res.clearCookie('_csrf');
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
redirect_url: safeRedirect, redirect_url: safeRedirect,
message: 'Login effettuato con successo' message: 'Login effettuato'
}); });
} catch (err) { } catch (err) {
if (err.message === 'No user matched' || err.message === 'Password mismatch') { if (err.code === 'INVALID_CREDENTIALS') {
return res.status(401).json(ERROR_RESPONSES.invalid_credentials); return res.status(401).json({
} success: false, error: 'invalid_credentials', message: 'Credenziali non valide'
if (err.message === 'User account is not active') {
return res.status(403).json({
success: false,
error: 'account_inactive',
message: 'Account disattivato'
}); });
} }
console.error('[AUTH] Login error:', err.message); if (err.code === 'ACCOUNT_INACTIVE') {
return res.status(500).json(ERROR_RESPONSES.internal); return res.status(403).json({
success: false, error: 'account_inactive', message: 'Account disattivato'
});
}
console.error('[AUTH] login:', err.message);
return res.status(500).json({
success: false, error: 'internal', message: 'Errore interno'
});
} }
}); });
// ─── POST /logout ──────────────────────────────────────────────────
router.post('/logout', async (req, res) => { router.post('/logout', async (req, res) => {
const token = req.cookies && req.cookies.auth_token; const token = req.cookies && req.cookies.auth_token;
if (token) { if (token) {
try { const v = jwt.verify(token);
const verified = jwt.verifyToken(token); if (v.valid) {
if (verified.valid) { try {
await auth.logout(verified.payload.session_id); await auth.revokeSession(v.payload.session_id);
} catch (err) {
console.error('[AUTH] logout revoke:', err.message);
} }
} catch (err) {
console.error('[AUTH] Logout error:', err.message);
} }
} }
const clearOptions = { httpOnly: true, sameSite: 'lax' }; res.clearCookie('auth_token', authCookieOptions(false));
if (COOKIE_DOMAIN) clearOptions.domain = COOKIE_DOMAIN;
res.clearCookie('auth_token', clearOptions); // Form HTML tradizionale → redirect, altrimenti JSON
if (req.accepts('html') && !req.xhr && !req.headers['content-type']?.includes('json')) {
return res.redirect('/login');
}
return res.status(200).json({ success: true, redirect_url: '/login' }); return res.status(200).json({ success: true, redirect_url: '/login' });
}); });
// ─── GET /verify (introspection per altri servizi) ─────────────────
router.get('/verify', async (req, res) => {
const token = (req.cookies && req.cookies.auth_token) || jwt.bearer(req.headers.authorization);
if (!token) return res.status(401).json({ valid: false, error: 'no_token' });
const v = jwt.verify(token);
if (!v.valid) return res.status(401).json({ valid: false, error: `token_${v.reason}` });
try {
await auth.validateSession(v.payload.session_id);
} catch (err) {
return res.status(401).json({ valid: false, error: err.code || 'session_invalid' });
}
return res.status(200).json({ valid: true, user: v.payload });
});
module.exports = router; module.exports = router;

View File

@@ -1,55 +1,37 @@
// api.mebboat.it/api/sessions
const router = require('express').Router(); const router = require('express').Router();
const { query } = require('../storage/database'); const auth = require('../core/auth.core');
const userAuth = require('../middlewares/user.security'); const userAuth = require('../middlewares/user.security');
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Tutte le route richiedono autenticazione utente
router.use(userAuth); router.use(userAuth);
// Mostra SOLO le sessioni dell'utente autenticato (non di tutti!) // GET / — Lista sessioni dell'utente corrente
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const result = await query( const rows = await auth.listSessions(req.user.user_id);
`SELECT id, ip_address, browser, os, device_type, res.json(rows);
location_country, location_city, created_at, last_active, is_revoked
FROM sessions
WHERE user_id = $1
ORDER BY last_active DESC`,
[req.user.user_id]
);
res.json(result.rows);
} catch (err) { } catch (err) {
console.error('[SESSIONS] Errore recupero sessioni:', err); console.error('[SESSIONS] list:', err.message);
res.status(500).json({ error: 'Errore interno del server' }); res.status(500).json({ error: 'internal' });
} }
}); });
// Revoca una sessione specifica dell'utente // DELETE /:sessionId — Revoca una sessione specifica
router.delete('/:sessionId', async (req, res) => { router.delete('/:sessionId', async (req, res) => {
const { sessionId } = req.params; const { sessionId } = req.params;
// Validazione UUID
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(sessionId)) { if (!UUID_REGEX.test(sessionId)) {
return res.status(400).json({ error: 'ID sessione non valido' }); return res.status(400).json({ error: 'invalid_session_id' });
} }
try { try {
// Verifica che la sessione appartenga all'utente autenticato const revoked = await auth.revokeSession(sessionId, req.user.user_id);
const result = await query( if (!revoked) return res.status(404).json({ error: 'session_not_found' });
'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND user_id = $2 AND is_revoked = FALSE', res.json({ success: true });
[sessionId, req.user.user_id]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: 'Sessione non trovata o già revocata' });
}
res.json({ success: true, message: 'Sessione revocata' });
} catch (err) { } catch (err) {
console.error('[SESSIONS] Errore revoca sessione:', err); console.error('[SESSIONS] revoke:', err.message);
res.status(500).json({ error: 'Errore interno del server' }); res.status(500).json({ error: 'internal' });
} }
}); });
module.exports = router; module.exports = router;

View File

@@ -1,122 +1,76 @@
// api.mebboat.it/api/users
const router = require('express').Router(); const router = require('express').Router();
const { query } = require('../storage/database'); const auth = require('../core/auth.core');
const userAuth = require('../middlewares/user.security'); const userAuth = require('../middlewares/user.security');
const internalAuth = require('../middlewares/internal.security'); const internalAuth = require('../middlewares/internal.security');
// ─── VALIDAZIONE INPUT ──────────────────────────────────────────────
const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/; const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/;
const TELEGRAM_ID_REGEX = /^[0-9]{5,15}$/; const TELEGRAM_REGEX = /^[0-9]{5,15}$/;
// ─── ROTTE INTERNAL (prima del router.use userAuth) ───────────────── // ─── SERVICE-TO-SERVICE (x-internal-api-key) ────────────────────────
router.get('/', internalAuth, async (req, res) => { router.get('/', internalAuth, async (req, res) => {
try { try {
const result = await query( const users = await auth.getAllUsers();
'SELECT id, username, is_active, created_at, telegram_id FROM users' res.json(users);
);
res.json(result.rows);
} catch (err) { } catch (err) {
console.error('[USERS] Errore lista utenti:', err); console.error('[USERS] list:', err.message);
res.status(500).json({ error: 'Errore interno del server' }); res.status(500).json({ error: 'internal' });
} }
}); });
router.get('/tonotify', internalAuth, async (req, res) => { router.get('/tonotify', internalAuth, async (req, res) => {
try { try {
const result = await query( const users = await auth.getUsersToNotify();
'SELECT telegram_id FROM users WHERE telegram_id IS NOT NULL' res.json(users);
);
res.json(result.rows);
} catch (err) { } catch (err) {
console.error('[USERS] Errore lista notifiche:', err); console.error('[USERS] tonotify:', err.message);
res.status(500).json({ error: 'Errore interno del server' }); res.status(500).json({ error: 'internal' });
} }
}); });
// ─── ROTTE USER (tutte le rotte sotto usano userAuth) ─────────────── // ─── USER AUTH (cookie/JWT) ─────────────────────────────────────────
router.use(userAuth); router.use(userAuth);
router.get('/me', async (req, res) => { router.get('/me', async (req, res) => {
try { try {
const result = await query( const user = await auth.getUserById(req.user.user_id);
'SELECT id, username, is_active, created_at, telegram_id FROM users WHERE id = $1', if (!user) return res.status(404).json({ error: 'user_not_found' });
[req.user.user_id] res.json(user);
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Utente non trovato' });
}
res.json(result.rows[0]);
} catch (err) { } catch (err) {
console.error('[USERS] Errore recupero utente:', err); console.error('[USERS] me:', err.message);
res.status(500).json({ error: 'Errore interno del server' }); res.status(500).json({ error: 'internal' });
} }
}); });
router.put('/me/username', async (req, res) => { router.put('/me/username', async (req, res) => {
const newUsername = req.query.newUsername || req.body?.newUsername; const newUsername = req.body?.newUsername || req.query.newUsername;
if (!newUsername || typeof newUsername !== 'string' || !USERNAME_REGEX.test(newUsername)) {
if (!newUsername || typeof newUsername !== 'string') { return res.status(400).json({ error: 'invalid_username' });
return res.status(400).json({ error: 'Nuovo username richiesto' });
} }
// Validazione formato username
if (!USERNAME_REGEX.test(newUsername)) {
return res.status(400).json({
error: 'Username non valido. Deve contenere 3-50 caratteri alfanumerici, underscore, punto o trattino.'
});
}
try { try {
const result = await query( const updated = await auth.updateUsername(req.user.user_id, newUsername);
'UPDATE users SET username = $1 WHERE id = $2 RETURNING username', if (!updated) return res.status(404).json({ error: 'user_not_found' });
[newUsername, req.user.user_id] res.json({ success: true, username: updated.username });
);
if (result.rowCount === 0) {
return res.status(404).json({ error: 'Utente non trovato' });
}
res.json({ success: true, username: result.rows[0].username });
} catch (err) { } catch (err) {
if (err.code === '23505') { if (err.code === '23505') return res.status(409).json({ error: 'username_taken' });
return res.status(409).json({ error: 'Questo username è già in uso' }); console.error('[USERS] update username:', err.message);
} res.status(500).json({ error: 'internal' });
console.error('[USERS] Errore aggiornamento username:', err);
res.status(500).json({ error: 'Errore interno del server' });
} }
}); });
router.put('/me/telegram', async (req, res) => { router.put('/me/telegram', async (req, res) => {
const telegramId = req.query.telegramId || req.body?.telegramId; const telegramId = req.body?.telegramId || req.query.telegramId;
if (!telegramId || typeof telegramId !== 'string' || !TELEGRAM_REGEX.test(telegramId)) {
if (!telegramId || typeof telegramId !== 'string') { return res.status(400).json({ error: 'invalid_telegram_id' });
return res.status(400).json({ error: 'Telegram ID richiesto' });
} }
// Validazione formato Telegram ID (solo numeri, 5-15 cifre)
if (!TELEGRAM_ID_REGEX.test(telegramId)) {
return res.status(400).json({
error: 'Telegram ID non valido. Deve contenere solo numeri (5-15 cifre).'
});
}
try { try {
await query( await auth.updateTelegram(req.user.user_id, telegramId);
'UPDATE users SET telegram_id = $1 WHERE id = $2', res.json({ success: true });
[telegramId, req.user.user_id]
);
res.json({ success: true, message: 'Telegram ID aggiornato' });
} catch (err) { } catch (err) {
if (err.code === '23505') { if (err.code === '23505') return res.status(409).json({ error: 'telegram_taken' });
return res.status(409).json({ error: 'Questo Telegram ID è già associato a un altro account' }); console.error('[USERS] update telegram:', err.message);
} res.status(500).json({ error: 'internal' });
console.error('[USERS] Errore aggiornamento telegram:', err);
res.status(500).json({ error: 'Errore interno del server' });
} }
}); });

View File

@@ -1,25 +1,28 @@
const router = require('express').Router(); const router = require('express').Router();
const crypto = require('crypto'); const { csrfToken } = require('../../tools/security');
const ERROR_MESSAGES = { const ERROR_MESSAGES = {
invalid_credentials: 'Credenziali non valide', invalid_credentials: 'Credenziali non valide',
invalid_redirect: 'Redirect non autorizzato', csrf: 'Richiesta non valida, riprova',
csrf: 'Richiesta non valida, riprova' account_inactive: 'Account disattivato',
session_expired: 'Sessione scaduta, effettua nuovamente il login'
}; };
router.get('/login', (req, res) => { router.get('/login', (req, res) => {
const redirect = req.query.redirect || ''; const redirect = typeof req.query.redirect === 'string' ? req.query.redirect : '';
const errorKey = req.query.error; const errorKey = req.query.error;
const error = ERROR_MESSAGES[errorKey] || null; const error = ERROR_MESSAGES[errorKey] || null;
const csrfToken = crypto.randomBytes(32).toString('hex'); const token = csrfToken();
res.cookie('_csrf', csrfToken, { res.cookie('_csrf', token, {
httpOnly: true, httpOnly: true,
sameSite: 'strict', sameSite: 'strict',
secure: process.env.NODE_ENV === 'production' secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 30 * 60 * 1000
}); });
res.render('loginpage', { error, redirect, csrf_token: csrfToken }); res.render('loginpage', { error, redirect, csrf_token: token });
}); });
module.exports = router; module.exports = router;

View File

@@ -7,4 +7,4 @@ router.get('/', (req, res) => {
res.render('sessions', { user: req.user }); res.render('sessions', { user: req.user });
}); });
module.exports = router; module.exports = router;

View File

@@ -1,69 +1,52 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const secret = process.env.JWT_SECRET; const SECRET = process.env.JWT_SECRET;
const expires_in = process.env.JWT_EXPIRES_IN; const EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
/** /**
* Genera un JWT Token a partire dall'utente e crea una nuova sessione * Firma un JWT per l'utente e la sessione.
* * Payload: { sub: userId, username, session_id }
* Uso dell'algoritmo HS256 per firmare il token con JWT_SECRET
*
* @param {Object} user - Utente
* @param {string} sessionID - ID della sessione
* @returns {string} - JWT Token
*/ */
function generateToken(user, sessionID) { function sign(user, sessionId) {
const payload = { return jwt.sign(
sub: user.id, { sub: user.id, username: user.username, session_id: sessionId },
username: user.username, SECRET,
session_id: sessionID, { algorithm: 'HS256', expiresIn: EXPIRES_IN }
iat: Math.floor(Date.now() / 1000) );
};
return jwt.sign(payload, secret, { expiresIn: expires_in, algorithm: 'HS256' });
} }
/** /**
* Verifica e decodifica il token * Verifica e decodifica un token.
* @param {string} token - JWT Token * @returns {{ valid: boolean, payload?: Object, reason?: string }}
* @returns {{valid: boolean, payload?: Object, error?: string, reason?: string}} - Il risultato della verifica. Se fallisce restituisce errore e motivo, altrimenti restituisce una conferma e il payload completo
*/ */
function verifyToken(token) { function verify(token) {
try { try {
const payload = jwt.verify(token, secret, { const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
algorithms: ['HS256']
});
return { return {
valid: true, valid: true,
payload: { payload: {
user_id: payload.sub, user_id: p.sub,
username: payload.username, username: p.username,
session_id: payload.session_id, session_id: p.session_id,
iat: payload.iat, iat: p.iat,
exp: payload.exp exp: p.exp
} }
}; };
} catch (err) { } catch (err) {
const reason = err.name === 'TokenExpiredError' ? 'expired' : 'invalid';
return { return {
valid: false, valid: false,
error: err.message, reason: err.name === 'TokenExpiredError' ? 'expired' : 'invalid'
reason: `token ${reason}`
}; };
} }
} }
function getToken(header) { /**
if (!header) return null; * Estrae il token da un header Authorization: Bearer <token>.
*/
const parts = header.split(' '); function bearer(header) {
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') { if (!header || typeof header !== 'string') return null;
return parts[1]; const [scheme, token] = header.split(' ');
} return scheme && scheme.toLowerCase() === 'bearer' && token ? token : null;
return null;
} }
module.exports = { generateToken, verifyToken, getToken }; module.exports = { sign, verify, bearer };

View File

@@ -1,54 +1,22 @@
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const crypto = require('crypto'); const crypto = require('crypto');
const saltRounds = 12; const SALT_ROUNDS = 12;
/**
* Genera un hash di una password
* @param {string} password - Password da hashare
* @returns {string} - Hash della password
*/
async function hashPassword(password) { async function hashPassword(password) {
return bcrypt.hash(password, saltRounds); return bcrypt.hash(password, SALT_ROUNDS);
} }
/**
* Verifica una password
* @param {string} password - Password da verificare
* @param {string} hash - Hash della password
* @returns {boolean} - True se la password è corretta, false altrimenti
*/
async function verifyPassword(password, hash) { async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash); return bcrypt.compare(password, hash);
} }
/** function sessionCode() {
* Create a session token from code and username
* Format: XXXXXXXX-base64_username
* @param {string} sessionCode
* @param {string} username
* @returns {string} Session token
*/
function generateSessionCode() {
return crypto.randomBytes(32).toString('base64url'); return crypto.randomBytes(32).toString('base64url');
} }
/** function csrfToken() {
* Parse a session token return crypto.randomBytes(32).toString('hex');
* @param {string} token
* @returns {string|null} The session token itself if valid
*/
function parseSessionToken(token) {
if (!token || typeof token !== 'string' || token.length < 32 || token.length > 64) {
return null;
}
return token;
} }
module.exports = { module.exports = { hashPassword, verifyPassword, sessionCode, csrfToken };
hashPassword,
verifyPassword,
generateSessionCode,
parseSessionToken
};

View File

@@ -1,24 +1,15 @@
//TODO: Verfica se serve davvero prendere le info come ip e browser const { UAParser } = require('ua-parser-js');
const { UAParser: parser } = require('ua-parser-js');
/** /**
* Estrae le informazioni base su browser, sistema operativo e dispositivo per identificare meglio la sessione dell'utente * Estrae browser/os/device dal user-agent per identificare meglio la sessione.
* @param {*} userAgent
* @returns
*/ */
function getBasicMetadata(userAgent) { function extract(userAgent) {
const parsed = parser(userAgent); const r = new UAParser(userAgent || '').getResult();
return { return {
browser: parsed.browser.name, browser: r.browser.name || null,
os: parsed.os.name, os: r.os.name || null,
device_type: parsed.device.type, device_type: r.device.type || 'desktop'
};
}
}
module.exports = {
getBasicMetadata
} }
module.exports = { extract };

View File

@@ -1,10 +1,10 @@
const express = require('express'); const express = require('express');
const nunjucks = require('nunjucks'); const nunjucks = require('nunjucks');
const path = require('path'); const path = require('path');
const jwt = require('jsonwebtoken');
const parser = require('cookie-parser'); const parser = require('cookie-parser');
const { requireAuthHtml } = require('./middlewares/auth');
const app = express(); const app = express();
const PORT = process.env.PORT; const PORT = process.env.PORT;
@@ -47,39 +47,9 @@ const renderPage = (page, extra = {}) => (req, res) => {
res.render(page, {current_path: req.path, ...extra}) res.render(page, {current_path: req.path, ...extra})
} }
// Middleware di autenticazione per le pagine // Middleware di autenticazione per tutte le pagine protette
app.use((req, res, next) => { // Le route /health e /static sono già gestite sopra
if (req.path === '/health' || req.path.startsWith('/static')) { app.use(requireAuthHtml);
return next();
}
const authBase = process.env.AUTH_LOGIN_URL || 'http://localhost:3006/login';
// Costruisci l'URL di redirect-back: protocollo + host + path originale
const proto = req.protocol;
const host = req.get('host');
const redirectBack = `${proto}://${host}${req.originalUrl}`;
const loginUrl = `${authBase}?redirect=${encodeURIComponent(redirectBack)}`;
const token = req.cookies && req.cookies.auth_token;
if (!token) {
return res.redirect(loginUrl);
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
req.user = payload;
next();
} catch (err) {
const clearOptions = { httpOnly: true, sameSite: 'lax' };
if (process.env.COOKIE_DOMAIN) {
clearOptions.domain = process.env.COOKIE_DOMAIN;
}
res.clearCookie('auth_token', clearOptions);
return res.redirect(loginUrl);
}
});
app.get('/dashboard', renderPage('dashboard')); app.get('/dashboard', renderPage('dashboard'));
app.get('/live', renderPage('live', { app.get('/live', renderPage('live', {

View File

@@ -0,0 +1,74 @@
/**
* Middleware di autenticazione condiviso per la console.
* Usa il JWT in cookie `auth_token` (condiviso tra i sottodomini via COOKIE_DOMAIN = .mebboat.it)
* oppure il header `Authorization: Bearer <token>`.
*
* Il JWT viene firmato da auth.mebboat.it con JWT_SECRET; questo servizio lo verifica
* localmente usando lo stesso secret. Nessuna chiamata di rete richiesta.
*/
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
const AUTH_LOGIN_URL = process.env.AUTH_LOGIN_URL || 'http://localhost:3006/login';
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
function extractToken(req) {
const header = req.headers.authorization;
const bearer = header && header.startsWith('Bearer ') ? header.slice(7) : null;
return (req.cookies && req.cookies.auth_token) || bearer || null;
}
function verifyToken(token) {
if (!token || typeof token !== 'string' || token.length > 2048) return null;
try {
const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
return {
user_id: p.sub,
username: p.username,
session_id: p.session_id,
iat: p.iat,
exp: p.exp
};
} catch {
return null;
}
}
function clearAuthCookie(res) {
const opts = { httpOnly: true, sameSite: 'lax', path: '/' };
if (COOKIE_DOMAIN) opts.domain = COOKIE_DOMAIN;
res.clearCookie('auth_token', opts);
}
function loginRedirectUrl(req) {
const back = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
return `${AUTH_LOGIN_URL}?redirect=${encodeURIComponent(back)}`;
}
/**
* Pagine HTML: su fallimento redirige all'auth service (SSO).
* Il redirect-back URL viene costruito automaticamente dalla richiesta corrente.
*/
function requireAuthHtml(req, res, next) {
const token = extractToken(req);
const user = verifyToken(token);
if (!user) {
if (token) clearAuthCookie(res);
return res.redirect(loginRedirectUrl(req));
}
req.user = user;
next();
}
/**
* API JSON: su fallimento risponde 401.
*/
function requireAuthApi(req, res, next) {
const user = verifyToken(extractToken(req));
if (!user) return res.status(401).json({ error: 'unauthorized' });
req.user = user;
next();
}
module.exports = { requireAuthHtml, requireAuthApi, clearAuthCookie, verifyToken, extractToken };

85
ml/core/auth.py Normal file
View File

@@ -0,0 +1,85 @@
"""
Middleware / dependency di autenticazione per FastAPI (servizio ML).
Verifica il JWT firmato da auth.mebboat.it (JWT_SECRET condiviso).
Supporta cookie `auth_token` (SSO via .mebboat.it) e header Authorization: Bearer <jwt>.
Il cookie auth_token è condiviso tra i sottodomini grazie a domain=.mebboat.it:
- console.mebboat.it imposta il cookie al login
- ml.mebboat.it lo riceve automaticamente dal browser
Uso:
from core.auth import require_auth, require_internal
@app.get("/protected")
async def protected_route(user = Depends(require_auth)):
return {"user": user}
"""
import os
from typing import Optional
import jwt
from fastapi import Cookie, Header, HTTPException, Request, status
SECRET = os.environ.get("JWT_SECRET")
INTERNAL_KEY = os.environ.get("INTERNAL_API_KEY")
def _verify(token: Optional[str]):
"""Verifica e decodifica un JWT. Ritorna il payload o None."""
if not token or not isinstance(token, str) or len(token) > 2048:
return None
try:
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
return {
"user_id": payload.get("sub"),
"username": payload.get("username"),
"session_id": payload.get("session_id"),
"iat": payload.get("iat"),
"exp": payload.get("exp"),
}
except jwt.PyJWTError:
return None
async def require_auth(
request: Request,
auth_token: Optional[str] = Cookie(default=None),
authorization: Optional[str] = Header(default=None),
x_api_key: Optional[str] = Header(default=None),
):
"""
FastAPI dependency: accetta utente loggato (cookie/bearer) o chiamata interna.
Uso: `user = Depends(require_auth)`.
Il cookie auth_token arriva automaticamente dal browser se l'utente
ha effettuato il login su auth.mebboat.it (dominio .mebboat.it).
"""
# Service-to-service
if x_api_key and INTERNAL_KEY and x_api_key == INTERNAL_KEY:
request.state.internal = True
return {"internal": True}
# Bearer token
bearer = None
if authorization and authorization.startswith("Bearer "):
bearer = authorization[7:]
user = _verify(auth_token or bearer)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
)
request.state.user = user
return user
async def require_internal(x_api_key: Optional[str] = Header(default=None)):
"""FastAPI dependency: solo chiamate service-to-service con x-api-key."""
if not INTERNAL_KEY or x_api_key != INTERNAL_KEY:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return True

View File

@@ -1,2 +1,3 @@
fastapi fastapi
uvicorn uvicorn
PyJWT

View File

@@ -11,8 +11,10 @@
"@influxdata/influxdb-client": "^1.35.0", "@influxdata/influxdb-client": "^1.35.0",
"@influxdata/influxdb-client-apis": "^1.35.0", "@influxdata/influxdb-client-apis": "^1.35.0",
"@msgpack/msgpack": "^3.1.3", "@msgpack/msgpack": "^3.1.3",
"cookie-parser": "^1.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"pg": "^8.20.0", "pg": "^8.20.0",
"ws": "^8.19.0" "ws": "^8.19.0"
} }

View File

@@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const crypto = require('crypto'); const crypto = require('crypto');
const parser = require('cookie-parser');
const app = express(); const app = express();
const db = require('./store/db') const db = require('./store/db')
@@ -7,6 +8,7 @@ const redis = require('./store/redis');
const wsHandler = require('./ws/handler'); const wsHandler = require('./ws/handler');
app.use(express.json()); app.use(express.json());
app.use(parser());
// CORS — consenti richieste dalla console e altri client browser // CORS — consenti richieste dalla console e altri client browser
app.use((req, res, next) => { app.use((req, res, next) => {

View File

@@ -0,0 +1,51 @@
/**
* Middleware di autenticazione per il servizio realtime.
* Usa il JWT condiviso via cookie .mebboat.it o Authorization Bearer.
* Il JWT viene firmato da auth.mebboat.it con JWT_SECRET e verificato localmente.
*/
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
const INTERNAL_KEY = process.env.INTERNAL_API_KEY;
function extractToken(req) {
const header = req.headers.authorization;
const bearer = header && header.startsWith('Bearer ') ? header.slice(7) : null;
return (req.cookies && req.cookies.auth_token) || bearer || null;
}
function verifyToken(token) {
if (!token || typeof token !== 'string' || token.length > 2048) return null;
try {
const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
return {
user_id: p.sub,
username: p.username,
session_id: p.session_id,
iat: p.iat,
exp: p.exp
};
} catch {
return null;
}
}
/**
* Accetta utente loggato (cookie/bearer) o chiamata interna (x-api-key).
*/
function requireAuth(req, res, next) {
// Service-to-service
const apiKey = req.headers['x-api-key'];
if (apiKey && INTERNAL_KEY && apiKey === INTERNAL_KEY) {
req.internal = true;
return next();
}
// User auth
const user = verifyToken(extractToken(req));
if (!user) return res.status(401).json({ error: 'unauthorized' });
req.user = user;
next();
}
module.exports = { requireAuth, verifyToken, extractToken };