diff --git a/.env.example b/.env.example index e69de29..cd09878 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,4 @@ +DOMAIN= + +#production= mebboat.it +#development= localhost \ No newline at end of file diff --git a/.gitignore b/.gitignore index a256828..4547e93 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ Thumbs.db .vscode/ .idea/ **/tsconfig.tsbuildinfo -.eslintcache \ No newline at end of file +.eslintcache + +.venv/ \ No newline at end of file diff --git a/api/src/index.js b/api/src/index.js index 528cfc9..21b218a 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -1,6 +1,7 @@ const express = require('express'); const parser = require('cookie-parser'); -const jwt = require('jsonwebtoken'); + +const { requireAuth } = require('./middlewares/auth'); const app = express(); const PORT = process.env.PORT; @@ -56,33 +57,8 @@ app.get('/health', async (req, res) => { const paramsSensorRoutes = require('./routes/params.sensor'); app.use('/params/sensor', paramsSensorRoutes); -// Middleware di autenticazione per le API -app.use((req, res, next) => { - 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' }); - } -}); +// Middleware di autenticazione per tutte le API protette +app.use(requireAuth); const dataRoutes = require('./routes/data'); app.use('/data', dataRoutes); diff --git a/api/src/middlewares/auth.js b/api/src/middlewares/auth.js new file mode 100644 index 0000000..bb2784d --- /dev/null +++ b/api/src/middlewares/auth.js @@ -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 + * + * 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 }; diff --git a/auth/src/core/auth.core.js b/auth/src/core/auth.core.js index 203038e..a89c71f 100644 --- a/auth/src/core/auth.core.js +++ b/auth/src/core/auth.core.js @@ -1,120 +1,166 @@ -const query = require('../storage/database').query; -const track = require('../tools/tracking') const { v4: uuid } = require('uuid'); -const security = require('../tools/security') +const { query } = require('../storage/database'); +const security = require('../tools/security'); +const tracking = require('../tools/tracking'); +// ─── ERRORI CUSTOM ────────────────────────────────────────────────── + +class AuthError extends Error { + constructor(code, message) { + super(message || code); + this.code = code; + } +} + +// ─── REGISTRAZIONE ────────────────────────────────────────────────── -/** - * Registra un nuovo utente - */ async function register(username, password) { - const userExists = await query('SELECT id FROM users WHERE username = $1', [username]); + 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'); - if (userExists.rows.length > 0) { - throw new Error('User already exists'); - } - - const hashedPassword = security.hashPassword(password); + const hash = 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 - } - }; + await query( + 'INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)', + [id, username, hash] + ); + return { id, username }; } +// ─── LOGIN ────────────────────────────────────────────────────────── -/** - * Esegue il login di un utente - */ async function login(username, password) { - const result = await query('SELECT id, username, password_hash, active, created_at FROM users WHERE username = $1', [username]); - if (result.rows.length === 0) { - throw new Error('No user matched') - } + const { rows } = await query( + 'SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1', + [username] + ); + if (!rows.length) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide'); - const user = result.rows[0]; - const isValid = await security.verifyPassword(password, user.password_hash); + const user = rows[0]; + if (!user.is_active) throw new AuthError('ACCOUNT_INACTIVE', 'Account disattivato'); - if (!isValid) { - throw new Error('Password mismatch') - } + const ok = await security.verifyPassword(password, user.password_hash); + if (!ok) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide'); - return { - id: user.id, - username: user.username, - created: user.created_at - } + return { id: user.id, username: user.username, created_at: user.created_at }; } -/** - * Esegue il logout di un utente - * - */ -async function logout(sessionID) { - if (!sessionID) { - throw new Error('no sessio id passed'); - } +// ─── SESSIONI ─────────────────────────────────────────────────────── - const result = await query('UPDATE sessions SET is_revoked = TRUE WHERE id = $1', [sessionID]); - return result.rowCount > 0; -} - -/** - * Crea una nuova sessione per un utente che ha appaena eseguito il login - */ -async function newSession(userId, userAgent, ip) { +async function createSession(userId, userAgent, ip) { const id = uuid(); - const sessionCode = security.generateSessionCode(); - const metadata = track.getBasicMetadata(userAgent); + const code = security.sessionCode(); + const meta = tracking.extract(userAgent); 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)`, - [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 }; +} + +async function validateSession(sessionId) { + if (!sessionId || typeof sessionId !== 'string') { + throw new AuthError('INVALID_SESSION', 'Sessione non valida'); + } + + const { rows } = await query( + `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] ); - return { id, sessionCode }; + if (!rows.length) throw new AuthError('INVALID_SESSION', 'Sessione non trovata'); + if (rows[0].is_revoked) throw new AuthError('SESSION_REVOKED', 'Sessione revocata'); + if (!rows[0].is_active) throw new AuthError('ACCOUNT_INACTIVE', 'Account disattivato'); + + // Aggiorna last_active in modo non bloccante + query('UPDATE sessions SET last_active = NOW() WHERE id = $1', [sessionId]).catch(() => {}); + + return true; } -/** - * Valida una sessione - */ -async function validateSession(token) { - const parsed = security.parseSessionToken(token); - - if (!parsed) { - throw new Error('Invalid token format'); - } - - const { code, username } = parsed; - - const result = await query('SELECT s.id as session_id, s.user_id, u.username, u.is_active, u.created_at FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.session_code = $1 AND s.is_revoked = FALSE', [code]); - if (result.rows.length === 0) { - throw new Error('Session not found or revoked') - } - - const session = result.rows[0]; - - if (session.username !== username) { - throw new Error('Session user mismatch'); - } - - if (!session.active) { - throw new Error('Session is not active'); +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 = { + AuthError, register, login, - logout, - newSession, - validateSession -} \ No newline at end of file + createSession, + validateSession, + revokeSession, + listSessions, + getUserById, + getAllUsers, + getUsersToNotify, + updateUsername, + updateTelegram +}; diff --git a/auth/src/core/session.core.js b/auth/src/core/session.core.js deleted file mode 100644 index 88a6b9b..0000000 --- a/auth/src/core/session.core.js +++ /dev/null @@ -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.code]); - 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 -}; - diff --git a/auth/src/index.js b/auth/src/index.js index d7268a0..9d9c487 100644 --- a/auth/src/index.js +++ b/auth/src/index.js @@ -61,10 +61,13 @@ const authRateLimit = createRateLimiter(RATE_LIMIT_AUTH_MAX); app.use((req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('X-XSS-Protection', '0'); // Disabilitato a favore di CSP + res.setHeader('X-XSS-Protection', '0'); res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); - // Rimuovi header che rivelano info sul server + res.setHeader( + 'Content-Security-Policy', + "default-src 'self'; style-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" + ); res.removeHeader('X-Powered-By'); next(); }); @@ -80,6 +83,7 @@ app.use(parser()); // ─── STATIC FILES ─────────────────────────────────────────────────── const staticFolder = path.join(__dirname, 'static'); app.use('/static', express.static(staticFolder)); +app.use('/api/static', express.static(staticFolder)); // ─── NUNJUCKS TEMPLATES ───────────────────────────────────────────── const templatesFolder = path.join(__dirname, 'templates'); @@ -112,14 +116,11 @@ app.use('/api/sessions', require('./routes/sessions')); // ─── HEALTH CHECK ─────────────────────────────────────────────────── app.get('/health', async (req, res) => { const dbConnected = await database.checkPostgres(); - const redisHelper = require('./storage/redis'); - const redisConnected = await redisHelper.checkRedis(); res.json({ - status: dbConnected && redisConnected ? "ok" : "degraded", + status: dbConnected ? "ok" : "degraded", service: "auth", database: dbConnected ? "connected" : "disconnected", - redis: redisConnected ? "connected" : "disconnected", version: version, build_number: vBuild, version_state: vState @@ -133,7 +134,7 @@ app.use((req, res) => { // ─── ERROR HANDLER GLOBALE ────────────────────────────────────────── app.use((err, req, res, _next) => { - console.error('[AUTH] Errore non gestito:', err); + console.error('[ERROR]', err.message, '| code:', err.code); res.status(500).json({ error: 'Errore interno del server' }); }); diff --git a/auth/src/middlewares/internal.security.js b/auth/src/middlewares/internal.security.js index 5228521..d8423e5 100644 --- a/auth/src/middlewares/internal.security.js +++ b/auth/src/middlewares/internal.security.js @@ -3,40 +3,30 @@ const crypto = require('crypto'); const API_KEY = process.env.INTERNAL_API_KEY; /** - * Middleware di autenticazione per servizi interni (container-to-container). - * Verifica l'header 'x-internal-api-key' contro INTERNAL_API_KEY nell'env. - * - * SICUREZZA: - * - Se INTERNAL_API_KEY non è configurata, TUTTE le richieste vengono rifiutate - * - Usa timingSafeEqual per prevenire attacchi timing side-channel + * Middleware: autentica chiamate service-to-service tramite header x-internal-api-key. + * Usa timing-safe comparison per prevenire timing attacks. */ -const internalAuth = (req, res, next) => { - // Se la chiave non è configurata nel server, blocca tutto +module.exports = function internalAuth(req, res, next) { if (!API_KEY) { - console.error('[SECURITY] INTERNAL_API_KEY absent! All internal requests blocked.'); - return res.status(503).json({ error: 'Service not configured correctly' }); + console.error('[SECURITY] INTERNAL_API_KEY mancante, blocco tutte le richieste interne.'); + return res.status(503).json({ error: 'service_not_configured' }); } - const internalToken = req.headers['x-internal-api-key']; - - if (!internalToken || typeof internalToken !== 'string') { - return res.status(403).json({ error: 'unauthorized' }); + const token = req.headers['x-internal-api-key']; + if (!token || typeof token !== 'string') { + return res.status(403).json({ error: 'forbidden' }); } - // Confronto timing-safe per prevenire timing attacks try { - const tokenBuffer = Buffer.from(internalToken, 'utf8'); - const keyBuffer = Buffer.from(API_KEY, 'utf8'); - - if (tokenBuffer.length !== keyBuffer.length || !crypto.timingSafeEqual(tokenBuffer, keyBuffer)) { - return res.status(403).json({ error: 'Accesso negato' }); + const a = Buffer.from(token, 'utf8'); + const b = Buffer.from(API_KEY, 'utf8'); + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { + return res.status(403).json({ error: 'forbidden' }); } } catch { - return res.status(403).json({ error: 'Accesso negato' }); + return res.status(403).json({ error: 'forbidden' }); } - req.user = { id: 'system', role: 'internal_service' }; - return next(); + req.internal = true; + next(); }; - -module.exports = internalAuth; diff --git a/auth/src/middlewares/user.security.js b/auth/src/middlewares/user.security.js index 8a6c501..c5f7a02 100644 --- a/auth/src/middlewares/user.security.js +++ b/auth/src/middlewares/user.security.js @@ -1,33 +1,37 @@ const jwt = require('../tools/jwt'); +const { validateSession } = require('../core/auth.core'); /** - * Middleware di autenticazione per utenti finali. - * Verifica il JWT dal cookie 'auth_token' o dall'header 'Authorization: Bearer '. + * Middleware: richiede un utente autenticato valido. + * Legge il token dal cookie `auth_token` o dall'header `Authorization: Bearer `. * - * Se valido, inietta req.user con { user_id, username, session_id }. + * Se la richiesta accetta HTML → redirect a /login con redirect-back URL. + * Altrimenti → 401 JSON. */ -const userAuth = (req, res, next) => { - const token = (req.cookies && req.cookies.auth_token) || jwt.getToken(req.headers['authorization']); +module.exports = async function userAuth(req, res, next) { + const token = (req.cookies && req.cookies.auth_token) || jwt.bearer(req.headers.authorization); - if (!token || typeof token !== 'string') { - return res.status(401).json({ error: 'Accesso negato: token mancante' }); + const unauthorized = (reason) => { + if (req.accepts('html') && !req.xhr) { + const r = encodeURIComponent(req.originalUrl); + return res.redirect(`/login?redirect=${r}`); + } + return res.status(401).json({ error: reason || 'unauthorized' }); + }; + + if (!token || typeof token !== 'string' || token.length > 2048) { + return unauthorized('missing_token'); } - // Limite ragionevole sulla lunghezza del token per evitare abusi - if (token.length > 2048) { - return res.status(400).json({ error: 'Token non valido' }); + const v = jwt.verify(token); + if (!v.valid) return unauthorized(`token_${v.reason}`); + + try { + await validateSession(v.payload.session_id); + } catch (err) { + return unauthorized(err.code || 'session_invalid'); } - const verified = jwt.verifyToken(token); - if (!verified.valid) { - return res.status(401).json({ - error: 'Sessione non valida o scaduta', - reason: verified.reason - }); - } - - req.user = verified.payload; + req.user = v.payload; next(); }; - -module.exports = userAuth; diff --git a/auth/src/routes/auth.js b/auth/src/routes/auth.js index 786055d..c8b5681 100644 --- a/auth/src/routes/auth.js +++ b/auth/src/routes/auth.js @@ -4,120 +4,175 @@ const jwt = require('../tools/jwt'); const CONSOLE_URL = process.env.CONSOLE_URL || 'http://localhost:3004'; const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined; +const IS_PROD = process.env.NODE_ENV === 'production'; -// Validazione input const USERNAME_REGEX = /^[a-zA-Z0-9_.\-]{3,50}$/; -const PASSWORD_MIN_LENGTH = 8; -const PASSWORD_MAX_LENGTH = 128; +const MIN_PASSWORD = 8; +const MAX_PASSWORD = 128; +const TOKEN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 giorni + +/** + * Opzioni cookie condivise per auth_token. + * 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) { + if (!redirect || typeof redirect !== 'string') return CONSOLE_URL; + try { + const target = new URL(redirect); + const console_ = new URL(CONSOLE_URL); + + const sameHost = target.hostname === console_.hostname; + const sameApex = COOKIE_DOMAIN + ? target.hostname.endsWith(COOKIE_DOMAIN.replace(/^\./, '')) + : false; + const notApi = !target.pathname.startsWith('/api/'); + + if ((sameHost || sameApex) && notApi) return redirect; + } catch { + // URL invalido / relativo: fallback + } + return CONSOLE_URL; +} + +// ─── POST /register ──────────────────────────────────────────────── router.post('/register', async (req, res) => { - const { username, password } = req.body; + const { username, password } = req.body || {}; - if (!username || !password) { - return res.status(400).json({ error: 'Username e password richiesti' }); + if (!username || !password || typeof username !== 'string' || typeof password !== 'string') { + return res.status(400).json({ success: false, error: 'username_and_password_required' }); } - - if (typeof username !== 'string' || typeof password !== 'string') { - return res.status(400).json({ error: 'Formato dati non valido' }); - } - if (!USERNAME_REGEX.test(username)) { - return res.status(400).json({ - error: 'Username non valido. 3-50 caratteri alfanumerici, underscore, punto o trattino.' - }); + return res.status(400).json({ success: false, error: 'invalid_username' }); } - - if (password.length < PASSWORD_MIN_LENGTH || password.length > PASSWORD_MAX_LENGTH) { - return res.status(400).json({ - error: `Password deve essere tra ${PASSWORD_MIN_LENGTH} e ${PASSWORD_MAX_LENGTH} caratteri` - }); + if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD) { + return res.status(400).json({ success: false, error: 'invalid_password_length' }); } try { - await auth.register(username, password); - res.status(201).end(); + const user = await auth.register(username, password); + return res.status(201).json({ success: true, user }); } catch (err) { - console.error('[AUTH] Register failed:', err.message); - const status = err.message === 'User already exists' ? 409 : 500; - res.status(status).json({ error: err.message === 'User already exists' ? err.message : 'Errore interno' }); + if (err.code === 'USER_EXISTS') { + return res.status(409).json({ success: false, error: 'user_exists' }); + } + console.error('[AUTH] register:', err.message); + return res.status(500).json({ success: false, error: 'internal' }); } }); +// ─── POST /login ─────────────────────────────────────────────────── + router.post('/login', async (req, res) => { - const { username, password, redirect } = req.body; + const { username, password, redirect, _csrf } = req.body || {}; - // Validazione base - if (!username || !password || typeof username !== 'string' || typeof password !== 'string') { - return res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' }); + // Verifica CSRF token + const csrfCookie = req.cookies && req.cookies._csrf; + if (!_csrf || !csrfCookie || _csrf !== csrfCookie) { + return res.status(400).json({ + success: false, error: 'csrf', message: 'Richiesta non valida, riprova' + }); } - // Limiti di lunghezza per prevenire abuse - if (username.length > 50 || password.length > PASSWORD_MAX_LENGTH) { - return res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' }); + if (!username || !password || typeof username !== 'string' || typeof password !== 'string' + || username.length > 50 || password.length > MAX_PASSWORD) { + return res.status(400).json({ + success: false, error: 'invalid_credentials', message: 'Credenziali non valide' + }); } - // Validazione redirect URL per prevenire open redirect attacks - if (redirect && typeof redirect === 'string') { - try { - const redirectUrl = new URL(redirect); - const consoleUrl = new URL(CONSOLE_URL); - // Permetti redirect solo allo stesso dominio del CONSOLE_URL - if (redirectUrl.hostname !== consoleUrl.hostname) { - return res.render('loginpage', { error: 'Redirect non autorizzato', redirect: '' }); - } - } catch { - // URL relativo o non valido — ignora il redirect - } - } + const safeRedirect = resolveSafeRedirect(redirect); try { const user = await auth.login(username, password); - const session = await auth.newSession(user.id, req.headers['user-agent'], req.ip); - const token = jwt.generateToken(user, session.id); + const session = await auth.createSession(user.id, req.headers['user-agent'], req.ip); + const token = jwt.sign(user, session.id); - const cookieOptions = { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 7 * 24 * 60 * 60 * 1000 - }; + // Imposta il cookie auth_token (condiviso tra sottodomini se COOKIE_DOMAIN è impostato) + res.cookie('auth_token', token, authCookieOptions(true)); + // Rimuove il cookie CSRF + res.clearCookie('_csrf', { httpOnly: true, sameSite: 'strict', path: '/' }); - if (COOKIE_DOMAIN) { - cookieOptions.domain = COOKIE_DOMAIN; - } - - res.cookie('auth_token', token, cookieOptions); - - const destination = redirect || CONSOLE_URL; - res.redirect(destination); + return res.status(200).json({ + success: true, + redirect_url: safeRedirect, + message: 'Login effettuato' + }); } catch (err) { - console.error('[AUTH] Login failed:', err.message); - // Mai rivelare se è l'utente o la password ad essere sbagliati - res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' }); + if (err.code === 'INVALID_CREDENTIALS') { + return res.status(401).json({ + success: false, error: 'invalid_credentials', message: 'Credenziali non valide' + }); + } + if (err.code === 'ACCOUNT_INACTIVE') { + 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) => { const token = req.cookies && req.cookies.auth_token; - if (token) { - try { - const verified = jwt.verifyToken(token); - if (verified.valid) { - await auth.logout(verified.payload.session_id); + const v = jwt.verify(token); + if (v.valid) { + try { + 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' }; - if (COOKIE_DOMAIN) { - clearOptions.domain = COOKIE_DOMAIN; + res.clearCookie('auth_token', authCookieOptions(false)); + + // 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' }); +}); + +// ─── 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' }); } - res.clearCookie('auth_token', clearOptions); - res.redirect('/login'); + return res.status(200).json({ valid: true, user: v.payload }); }); module.exports = router; diff --git a/auth/src/routes/sessions.js b/auth/src/routes/sessions.js index 8bc787d..17e623f 100644 --- a/auth/src/routes/sessions.js +++ b/auth/src/routes/sessions.js @@ -1,55 +1,37 @@ -// api.mebboat.it/api/sessions - const router = require('express').Router(); -const { query } = require('../storage/database'); +const auth = require('../core/auth.core'); 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); -// Mostra SOLO le sessioni dell'utente autenticato (non di tutti!) +// GET / — Lista sessioni dell'utente corrente router.get('/', async (req, res) => { try { - const result = 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`, - [req.user.user_id] - ); - res.json(result.rows); + const rows = await auth.listSessions(req.user.user_id); + res.json(rows); } catch (err) { - console.error('[SESSIONS] Errore recupero sessioni:', err); - res.status(500).json({ error: 'Errore interno del server' }); + console.error('[SESSIONS] list:', err.message); + res.status(500).json({ error: 'internal' }); } }); -// Revoca una sessione specifica dell'utente +// DELETE /:sessionId — Revoca una sessione specifica router.delete('/:sessionId', async (req, res) => { 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)) { - return res.status(400).json({ error: 'ID sessione non valido' }); + return res.status(400).json({ error: 'invalid_session_id' }); } - try { - // Verifica che la sessione appartenga all'utente autenticato - const result = await query( - 'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND user_id = $2 AND is_revoked = FALSE', - [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' }); + const revoked = await auth.revokeSession(sessionId, req.user.user_id); + if (!revoked) return res.status(404).json({ error: 'session_not_found' }); + res.json({ success: true }); } catch (err) { - console.error('[SESSIONS] Errore revoca sessione:', err); - res.status(500).json({ error: 'Errore interno del server' }); + console.error('[SESSIONS] revoke:', err.message); + res.status(500).json({ error: 'internal' }); } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/auth/src/routes/users.js b/auth/src/routes/users.js index 48cac6e..68ba44e 100644 --- a/auth/src/routes/users.js +++ b/auth/src/routes/users.js @@ -1,122 +1,76 @@ -// api.mebboat.it/api/users - const router = require('express').Router(); -const { query } = require('../storage/database'); +const auth = require('../core/auth.core'); const userAuth = require('../middlewares/user.security'); const internalAuth = require('../middlewares/internal.security'); -// ─── VALIDAZIONE INPUT ────────────────────────────────────────────── 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) => { try { - const result = await query( - 'SELECT id, username, is_active, created_at, telegram_id FROM users' - ); - res.json(result.rows); + const users = await auth.getAllUsers(); + res.json(users); } catch (err) { - console.error('[USERS] Errore lista utenti:', err); - res.status(500).json({ error: 'Errore interno del server' }); + console.error('[USERS] list:', err.message); + res.status(500).json({ error: 'internal' }); } }); router.get('/tonotify', internalAuth, async (req, res) => { try { - const result = await query( - 'SELECT telegram_id FROM users WHERE telegram_id IS NOT NULL' - ); - res.json(result.rows); + const users = await auth.getUsersToNotify(); + res.json(users); } catch (err) { - console.error('[USERS] Errore lista notifiche:', err); - res.status(500).json({ error: 'Errore interno del server' }); + console.error('[USERS] tonotify:', err.message); + res.status(500).json({ error: 'internal' }); } }); -// ─── ROTTE USER (tutte le rotte sotto usano userAuth) ─────────────── +// ─── USER AUTH (cookie/JWT) ───────────────────────────────────────── router.use(userAuth); router.get('/me', async (req, res) => { try { - const result = await query( - 'SELECT id, username, is_active, created_at, telegram_id FROM users WHERE id = $1', - [req.user.user_id] - ); - - if (result.rows.length === 0) { - return res.status(404).json({ error: 'Utente non trovato' }); - } - - res.json(result.rows[0]); + const user = await auth.getUserById(req.user.user_id); + if (!user) return res.status(404).json({ error: 'user_not_found' }); + res.json(user); } catch (err) { - console.error('[USERS] Errore recupero utente:', err); - res.status(500).json({ error: 'Errore interno del server' }); + console.error('[USERS] me:', err.message); + res.status(500).json({ error: 'internal' }); } }); router.put('/me/username', async (req, res) => { - const newUsername = req.query.newUsername || req.body?.newUsername; - - if (!newUsername || typeof newUsername !== 'string') { - return res.status(400).json({ error: 'Nuovo username richiesto' }); + const newUsername = req.body?.newUsername || req.query.newUsername; + if (!newUsername || typeof newUsername !== 'string' || !USERNAME_REGEX.test(newUsername)) { + return res.status(400).json({ error: 'invalid_username' }); } - - // 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 { - const result = await query( - 'UPDATE users SET username = $1 WHERE id = $2 RETURNING username', - [newUsername, req.user.user_id] - ); - - if (result.rowCount === 0) { - return res.status(404).json({ error: 'Utente non trovato' }); - } - - res.json({ success: true, username: result.rows[0].username }); + const updated = await auth.updateUsername(req.user.user_id, newUsername); + if (!updated) return res.status(404).json({ error: 'user_not_found' }); + res.json({ success: true, username: updated.username }); } catch (err) { - if (err.code === '23505') { - return res.status(409).json({ error: 'Questo username è già in uso' }); - } - console.error('[USERS] Errore aggiornamento username:', err); - res.status(500).json({ error: 'Errore interno del server' }); + if (err.code === '23505') return res.status(409).json({ error: 'username_taken' }); + console.error('[USERS] update username:', err.message); + res.status(500).json({ error: 'internal' }); } }); router.put('/me/telegram', async (req, res) => { - const telegramId = req.query.telegramId || req.body?.telegramId; - - if (!telegramId || typeof telegramId !== 'string') { - return res.status(400).json({ error: 'Telegram ID richiesto' }); + const telegramId = req.body?.telegramId || req.query.telegramId; + if (!telegramId || typeof telegramId !== 'string' || !TELEGRAM_REGEX.test(telegramId)) { + return res.status(400).json({ error: 'invalid_telegram_id' }); } - - // 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 { - await query( - 'UPDATE users SET telegram_id = $1 WHERE id = $2', - [telegramId, req.user.user_id] - ); - res.json({ success: true, message: 'Telegram ID aggiornato' }); + await auth.updateTelegram(req.user.user_id, telegramId); + res.json({ success: true }); } catch (err) { - if (err.code === '23505') { - return res.status(409).json({ error: 'Questo Telegram ID è già associato a un altro account' }); - } - console.error('[USERS] Errore aggiornamento telegram:', err); - res.status(500).json({ error: 'Errore interno del server' }); + if (err.code === '23505') return res.status(409).json({ error: 'telegram_taken' }); + console.error('[USERS] update telegram:', err.message); + res.status(500).json({ error: 'internal' }); } }); diff --git a/auth/src/routes/views/auth.js b/auth/src/routes/views/auth.js index fdac9de..fa5493c 100644 --- a/auth/src/routes/views/auth.js +++ b/auth/src/routes/views/auth.js @@ -1,8 +1,28 @@ const router = require('express').Router(); +const { csrfToken } = require('../../tools/security'); + +const ERROR_MESSAGES = { + invalid_credentials: 'Credenziali non valide', + csrf: 'Richiesta non valida, riprova', + account_inactive: 'Account disattivato', + session_expired: 'Sessione scaduta, effettua nuovamente il login' +}; router.get('/login', (req, res) => { - const redirect = req.query.redirect || ''; - res.render('loginpage', { error: null, redirect }); + const redirect = typeof req.query.redirect === 'string' ? req.query.redirect : ''; + const errorKey = req.query.error; + const error = ERROR_MESSAGES[errorKey] || null; + + const token = csrfToken(); + res.cookie('_csrf', token, { + httpOnly: true, + sameSite: 'strict', + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: 30 * 60 * 1000 + }); + + res.render('loginpage', { error, redirect, csrf_token: token }); }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/auth/src/routes/views/sessions.js b/auth/src/routes/views/sessions.js index 4cc4edd..5a47234 100644 --- a/auth/src/routes/views/sessions.js +++ b/auth/src/routes/views/sessions.js @@ -4,7 +4,7 @@ const userAuth = require('../../middlewares/user.security'); router.use(userAuth); router.get('/', (req, res) => { - res.render('sessions'); + res.render('sessions', { user: req.user }); }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/auth/src/routes/views/user.js b/auth/src/routes/views/user.js index 01d0063..19dd9a8 100644 --- a/auth/src/routes/views/user.js +++ b/auth/src/routes/views/user.js @@ -1,7 +1,10 @@ const router = require('express').Router(); +const userAuth = require('../../middlewares/user.security'); + +router.use(userAuth); router.get('/', (req, res) => { - res.render('user'); + res.render('user', { user: req.user }); }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/auth/src/static/font/sans-flex.ttf b/auth/src/static/font/sans-flex.ttf new file mode 100644 index 0000000..6cd6eaf Binary files /dev/null and b/auth/src/static/font/sans-flex.ttf differ diff --git a/auth/src/static/style/style.css b/auth/src/static/style/style.css index 8b7defd..3c8b0e5 100644 --- a/auth/src/static/style/style.css +++ b/auth/src/static/style/style.css @@ -15,7 +15,8 @@ --header-border: #e2e8f0; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-md: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); --radius-md: 8px; --radius-lg: 12px; } @@ -26,21 +27,21 @@ } @font-face { - font-family: 'Normal'; - src: url('../font/Quicksand-VariableFont_wght.ttf'); + font-family: "Normal"; + src: url("../font/sans-flex.ttf"); font-weight: 400; font-style: normal; } @font-face { - font-family: 'Bold'; - src: url('../font/Quicksand-VariableFont_wght.ttf'); + font-family: "Bold"; + src: url("../font/sans-flex.ttf"); font-weight: 700; font-style: normal; } body { - font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-family: "Normal", Arial; color: var(--text-primary); -webkit-font-smoothing: antialiased; } @@ -53,7 +54,7 @@ button { color: var(--text-primary); font-size: 14px; font-weight: 600; - font-family: 'Bold', inherit; + font-family: "Bold", inherit; cursor: pointer; transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1); white-space: nowrap; @@ -79,13 +80,11 @@ button.prominent:hover { box-shadow: var(--shadow-md); } - button.prominent:active { transform: translateY(1px); box-shadow: var(--shadow-sm); } - /* INFO PANEL */ .info-panel { @@ -111,9 +110,6 @@ button.prominent:active { transition: transform 0.12s ease; } - - - /* GRID & CARD ITEMS */ .grid { @@ -132,7 +128,9 @@ button.prominent:active { border-radius: 20px; text-decoration: none; color: var(--text-primary); - transition: transform 0.12s ease, box-shadow 0.12s ease; + transition: + transform 0.12s ease, + box-shadow 0.12s ease; } .card h3 { @@ -158,10 +156,6 @@ button.prominent:active { grid-column: 1 / -1; } - - - - /* HEADER */ .header { @@ -179,7 +173,6 @@ button.prominent:active { user-select: none; } - .header h1 { color: var(--text-primary); font-size: 1.25rem; @@ -198,4 +191,4 @@ button.prominent:active { font-weight: 500; color: var(--text-secondary); padding-inline: 5px; -} \ No newline at end of file +} diff --git a/auth/src/storage/database.js b/auth/src/storage/database.js index 02cfc59..0a7cf41 100644 --- a/auth/src/storage/database.js +++ b/auth/src/storage/database.js @@ -5,58 +5,46 @@ const config = { password: process.env.DB_PASSWORD, host: process.env.DB_HOST, port: process.env.DB_PORT, + database: process.env.USERS_DB || process.env.DB_NAME, max: 10, idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000 -} +}; -const pool = new Pool({ ...config, database: process.env.USERS_DB }); +const pool = new Pool(config); pool.on('error', (err) => { - console.error('Error in database', err); + console.error('[DB] Pool error:', err.message); }); -/** - * Execute a query with parameters - * @param {string} text - SQL query - * @param {Array} params - Query parameters - * @returns {Promise} Query result - */ async function query(text, params) { const start = Date.now(); - const result = await pool.query(text, params); - const duration = Date.now() - start; - - if (duration > 100) { - console.warn(`[DB] Slow query (${duration}ms):`, text.substring(0, 80)); + try { + const result = await pool.query(text, params); + const duration = Date.now() - start; + if (duration > 100) { + console.warn(`[DB] Slow query (${duration}ms):`, text.substring(0, 80)); + } + return result; + } catch (err) { + console.error('[DB] Query failed:', err.message, '| code:', err.code); + throw err; } - - return result; } -/** - * Get a client from pool for transactions - * @returns {Promise} Pool client - */ async function getClient() { return await pool.connect(); } -/** - * Initialize database and ensure tables exist - */ async function initDb() { - // Test connection await pool.query('SELECT NOW()'); - // Ensure pgcrypto extension (provides gen_random_uuid) - // Note: creating extensions requires proper DB permissions (usually superuser in PG) + try { await pool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto;`); } catch (err) { - console.warn('[DB] Could not create pgcrypto extension (may require superuser):', err.message); + console.warn('[DB] Could not create pgcrypto extension:', err.message); } - // Ensure tables exist (UUID default generated by DB) await pool.query(` CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -76,7 +64,7 @@ async function initDb() { id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, session_code VARCHAR(64) NOT NULL, - encoded_username TEXT NOT NULL, + encoded_username TEXT NOT NULL DEFAULT '', ip_address INET, user_agent TEXT, browser VARCHAR(100), @@ -89,9 +77,6 @@ async function initDb() { is_revoked BOOLEAN DEFAULT FALSE ); - -- Altera colonna in base al nuovo standard token 32 byte - 64 url chars - ALTER TABLE sessions ALTER COLUMN session_code TYPE VARCHAR(64); - CREATE INDEX IF NOT EXISTS idx_sessions_code ON sessions(session_code); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); `); @@ -101,7 +86,7 @@ async function checkPostgres() { try { await pool.query('SELECT NOW()'); return true; - } catch (error) { + } catch { return false; } } diff --git a/auth/src/templates/loginpage.html b/auth/src/templates/loginpage.html index 460bc84..c3b31ae 100644 --- a/auth/src/templates/loginpage.html +++ b/auth/src/templates/loginpage.html @@ -1,39 +1,108 @@ - - + - - - + + + Login — Console MEB + + +
+ + - \ No newline at end of file + diff --git a/auth/src/templates/sessions.html b/auth/src/templates/sessions.html index 8b13789..e3c77dc 100644 --- a/auth/src/templates/sessions.html +++ b/auth/src/templates/sessions.html @@ -1 +1,109 @@ + + + + + + Sessioni — Console MEB + + + + + +
+

Console MEB

+
+

{{ user.username }}

+
+ +
+
+
+ +
+

Sessioni attive

+
+

Caricamento...

+
+
+ + + + + diff --git a/auth/src/templates/user.html b/auth/src/templates/user.html index 8b13789..39247bf 100644 --- a/auth/src/templates/user.html +++ b/auth/src/templates/user.html @@ -1 +1,87 @@ + + + + + + Profilo — Console MEB + + + + + +
+

Console MEB

+
+

{{ user.username }}

+
+ +
+
+
+ +
+

Profilo utente

+
+

Caricamento...

+
+
+ + + + + diff --git a/auth/src/tools/jwt.js b/auth/src/tools/jwt.js index a87fadb..c043388 100644 --- a/auth/src/tools/jwt.js +++ b/auth/src/tools/jwt.js @@ -1,70 +1,52 @@ const jwt = require('jsonwebtoken'); -const secret = process.env.JWT_SECRET; -const expires_in = process.env.JWT_EXPIRES_IN; +const SECRET = process.env.JWT_SECRET; +const EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; /** - * Genera un JWT Token a partire dall'utente e crea una nuova sessione - * - * 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 + * Firma un JWT per l'utente e la sessione. + * Payload: { sub: userId, username, session_id } */ -function generateToken(user, sessionID) { - const payload = { - sub: user.id, - username: user.username, - session_id: sessionID, - iat: Math.floor(Date.now() / 1000) - }; - - return jwt.sign(payload, secret, { expiresIn: expires_in, algorithm: 'HS256' }); +function sign(user, sessionId) { + return jwt.sign( + { sub: user.id, username: user.username, session_id: sessionId }, + SECRET, + { algorithm: 'HS256', expiresIn: EXPIRES_IN } + ); } /** - * Verifica e decodifica il token - * @param {string} token - JWT Token - * @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 + * Verifica e decodifica un token. + * @returns {{ valid: boolean, payload?: Object, reason?: string }} */ -function verifyToken(token) { +function verify(token) { try { - const payload = jwt.verify(token, secret, { - algorithms: ['HS256'] - }); + const p = jwt.verify(token, SECRET, { algorithms: ['HS256'] }); return { valid: true, payload: { - user_id: payload.sub, - username: payload.username, - session_id: payload.session_id, - iat: payload.iat, - exp: payload.exp + user_id: p.sub, + username: p.username, + session_id: p.session_id, + iat: p.iat, + exp: p.exp } }; } catch (err) { - - const reason = err.name === 'TokenExpiredError' ? 'expired' : 'invalid'; - return { valid: false, - error: err.message, - reason: `token ${reason}` + reason: err.name === 'TokenExpiredError' ? 'expired' : 'invalid' }; } } -function getToken(header) { - if (!header) return null; - - const parts = header.split(' '); - if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') { - return parts[1]; - } - - //TODO: valutare se modificare in return null per accettare solo metodo Bearer Authorization Token, - return header; +/** + * Estrae il token da un header Authorization: Bearer . + */ +function bearer(header) { + if (!header || typeof header !== 'string') return null; + const [scheme, token] = header.split(' '); + return scheme && scheme.toLowerCase() === 'bearer' && token ? token : null; } -module.exports = { generateToken, verifyToken, getToken }; \ No newline at end of file +module.exports = { sign, verify, bearer }; diff --git a/auth/src/tools/security.js b/auth/src/tools/security.js index 13d4c3b..2490307 100644 --- a/auth/src/tools/security.js +++ b/auth/src/tools/security.js @@ -1,54 +1,22 @@ const bcrypt = require('bcrypt'); 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 - */ -function hashPassword(password) { - return bcrypt.hashSync(password, saltRounds); +async function hashPassword(password) { + 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 - */ -function verifyPassword(password, hash) { - return bcrypt.compareSync(password, hash); +async function verifyPassword(password, hash) { + return bcrypt.compare(password, hash); } -/** - * Create a session token from code and username - * Format: XXXXXXXX-base64_username - * @param {string} sessionCode - * @param {string} username - * @returns {string} Session token - */ -function generateSessionCode() { +function sessionCode() { return crypto.randomBytes(32).toString('base64url'); } -/** - * Parse a session token - * @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; +function csrfToken() { + return crypto.randomBytes(32).toString('hex'); } -module.exports = { - hashPassword, - verifyPassword, - generateSessionCode, - parseSessionToken -}; +module.exports = { hashPassword, verifyPassword, sessionCode, csrfToken }; diff --git a/auth/src/tools/tracking.js b/auth/src/tools/tracking.js index 62287b9..ac82338 100644 --- a/auth/src/tools/tracking.js +++ b/auth/src/tools/tracking.js @@ -1,24 +1,15 @@ -//TODO: Verfica se serve davvero prendere le info come ip e browser - -const { UAParser: parser } = require('ua-parser-js'); +const { UAParser } = require('ua-parser-js'); /** - * Estrae le informazioni base su browser, sistema operativo e dispositivo per identificare meglio la sessione dell'utente - * @param {*} userAgent - * @returns + * Estrae browser/os/device dal user-agent per identificare meglio la sessione. */ -function getBasicMetadata(userAgent) { - const parsed = parser(userAgent); - +function extract(userAgent) { + const r = new UAParser(userAgent || '').getResult(); return { - browser: parsed.browser.name, - os: parsed.os.name, - device_type: parsed.device.type, - - } -} - -module.exports = { - getBasicMetadata + browser: r.browser.name || null, + os: r.os.name || null, + device_type: r.device.type || 'desktop' + }; } +module.exports = { extract }; diff --git a/console/src/index.js b/console/src/index.js index 1358213..ace74f0 100644 --- a/console/src/index.js +++ b/console/src/index.js @@ -1,10 +1,10 @@ const express = require('express'); const nunjucks = require('nunjucks'); const path = require('path'); -const jwt = require('jsonwebtoken'); - const parser = require('cookie-parser'); +const { requireAuthHtml } = require('./middlewares/auth'); + const app = express(); const PORT = process.env.PORT; @@ -47,39 +47,9 @@ const renderPage = (page, extra = {}) => (req, res) => { res.render(page, {current_path: req.path, ...extra}) } -// Middleware di autenticazione per le pagine -app.use((req, res, next) => { - if (req.path === '/health' || req.path.startsWith('/static')) { - 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); - } -}); +// Middleware di autenticazione per tutte le pagine protette +// Le route /health e /static sono già gestite sopra +app.use(requireAuthHtml); app.get('/dashboard', renderPage('dashboard')); app.get('/live', renderPage('live', { diff --git a/console/src/middlewares/auth.js b/console/src/middlewares/auth.js new file mode 100644 index 0000000..2643285 --- /dev/null +++ b/console/src/middlewares/auth.js @@ -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 `. + * + * 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 }; diff --git a/docker-compose.yml b/docker-compose.yml index b846e98..4006b75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: - "3006:3006" labels: - "traefik.enable=true" - - "traefik.http.routers.auth.rule=Host(`auth.mebboat.it`)" + - "traefik.http.routers.auth.rule=Host(`auth.${DOMAIN:-mebboat.it}`)" - "traefik.http.routers.auth.entrypoints=websecure" - "traefik.http.services.auth.loadbalancer.server.port=3006" - "traefik.docker.network=meb-public" @@ -46,7 +46,7 @@ services: - meb-private labels: - "traefik.enable=true" - - "traefik.http.routers.api.rule=Host(`api.mebboat.it`)" + - "traefik.http.routers.api.rule=Host(`api.${DOMAIN:-mebboat.it}`)" - "traefik.http.routers.api.entrypoints=websecure" - "traefik.http.services.api.loadbalancer.server.port=3003" - "traefik.http.routers.api.tls.certresolver=letsencrypt" @@ -68,7 +68,7 @@ services: - meb-private labels: - "traefik.enable=true" - - "traefik.http.routers.console.rule=Host(`console.mebboat.it`)" + - "traefik.http.routers.console.rule=Host(`console.${DOMAIN:-mebboat.it}`)" - "traefik.http.routers.console.entrypoints=websecure" - "traefik.http.services.console.loadbalancer.server.port=3004" - "traefik.http.routers.console.tls.certresolver=letsencrypt" @@ -90,7 +90,7 @@ services: - meb-public labels: - "traefik.enable=true" - - "traefik.http.routers.realtime.rule=Host(`realtime.mebboat.it`)" + - "traefik.http.routers.realtime.rule=Host(`realtime.${DOMAIN:-mebboat.it}`)" - "traefik.http.routers.realtime.entrypoints=websecure" - "traefik.http.services.realtime.loadbalancer.server.port=3000" - "traefik.http.routers.realtime.tls.certresolver=letsencrypt" @@ -115,7 +115,7 @@ services: - meb-public labels: - "traefik.enable=true" - - "traefik.http.routers.ml.rule=Host(`ml.mebboat.it`)" + - "traefik.http.routers.ml.rule=Host(`ml.${DOMAIN:-mebboat.it}`)" - "traefik.http.routers.ml.entrypoints=websecure" - "traefik.http.services.ml.loadbalancer.server.port=8000" - "traefik.http.routers.ml.tls.certresolver=letsencrypt" @@ -139,7 +139,7 @@ services: # - meb-internal # labels: # - "traefik.enable=true" -# - "traefik.http.routers.marine.rule=Host(`api.${URL_DOMAIN}`) && PathPrefix(`/marine`)" +# - "traefik.http.routers.marine.rule=Host(`api.${DOMAIN:-mebboat.it}`) && PathPrefix(`/marine`)" # - "traefik.http.routers.marine.entrypoints=web" # - "traefik.http.services.marine.loadbalancer.server.port=8001" # - "traefik.docker.network=meb-proxy-net" @@ -155,8 +155,8 @@ services: # environment: # - DATABASE_URL=postgresql://meb:meb@meb-postgres:5432/circuits # - AUTH_SERVICE_URL=http://auth:3001 -# - AUTH_URL=http://auth.${URL_DOMAIN:-localhost} -# - API_URL=http://api.${URL_DOMAIN:-localhost} +# - AUTH_URL=http://auth.${DOMAIN:-localhost} +# - API_URL=http://api.${DOMAIN:-localhost} # - NODE_ENV=${NODE_ENV:-development} # volumes: # - ./circuits/src:/app/src @@ -173,7 +173,7 @@ services: # - meb-internal # labels: # - "traefik.enable=true" -# - "traefik.http.routers.circuits.rule=Host(`circuits.${URL_DOMAIN}`)" +# - "traefik.http.routers.circuits.rule=Host(`circuits.${DOMAIN:-mebboat.it}`)" # - "traefik.http.routers.circuits.entrypoints=web" # - "traefik.http.services.circuits.loadbalancer.server.port=3005" # - "traefik.docker.network=meb-proxy-net" diff --git a/ml/core/auth.py b/ml/core/auth.py new file mode 100644 index 0000000..533188d --- /dev/null +++ b/ml/core/auth.py @@ -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 . + +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 diff --git a/ml/requirements.txt b/ml/requirements.txt index 97dc7cd..80345f5 100644 --- a/ml/requirements.txt +++ b/ml/requirements.txt @@ -1,2 +1,3 @@ fastapi uvicorn +PyJWT diff --git a/realtime/package.json b/realtime/package.json index 41b825f..90c7357 100644 --- a/realtime/package.json +++ b/realtime/package.json @@ -11,8 +11,10 @@ "@influxdata/influxdb-client": "^1.35.0", "@influxdata/influxdb-client-apis": "^1.35.0", "@msgpack/msgpack": "^3.1.3", + "cookie-parser": "^1.4.7", "express": "^5.2.1", "ioredis": "^5.10.0", + "jsonwebtoken": "^9.0.3", "pg": "^8.20.0", "ws": "^8.19.0" } diff --git a/realtime/src/index.js b/realtime/src/index.js index 78bb823..b778497 100644 --- a/realtime/src/index.js +++ b/realtime/src/index.js @@ -1,5 +1,6 @@ const express = require('express'); const crypto = require('crypto'); +const parser = require('cookie-parser'); const app = express(); const db = require('./store/db') @@ -7,6 +8,7 @@ const redis = require('./store/redis'); const wsHandler = require('./ws/handler'); app.use(express.json()); +app.use(parser()); // CORS — consenti richieste dalla console e altri client browser app.use((req, res, next) => { diff --git a/realtime/src/middlewares/auth.js b/realtime/src/middlewares/auth.js new file mode 100644 index 0000000..3b231c1 --- /dev/null +++ b/realtime/src/middlewares/auth.js @@ -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 };