Merge pull request 'enchant-workflow-and-auth' (#1) from enchant-betterworkflow into main
Reviewed-on: meb/server-architecture#1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
DOMAIN=
|
||||||
|
|
||||||
|
#production= mebboat.it
|
||||||
|
#development= localhost
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,4 +16,6 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
**/tsconfig.tsbuildinfo
|
**/tsconfig.tsbuildinfo
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
|
.venv/
|
||||||
@@ -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);
|
||||||
|
|||||||
70
api/src/middlewares/auth.js
Normal file
70
api/src/middlewares/auth.js
Normal 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 };
|
||||||
@@ -1,120 +1,166 @@
|
|||||||
const query = require('../storage/database').query;
|
|
||||||
const track = require('../tools/tracking')
|
|
||||||
const { v4: uuid } = require('uuid');
|
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) {
|
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) {
|
const hash = await security.hashPassword(password);
|
||||||
throw new Error('User already exists');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = security.hashPassword(password);
|
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
|
await query(
|
||||||
await query('INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)', [id, username, hashedPassword]);
|
'INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)',
|
||||||
|
[id, username, hash]
|
||||||
return {
|
);
|
||||||
success: true,
|
return { id, username };
|
||||||
user: {
|
|
||||||
id,
|
|
||||||
username
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── LOGIN ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Esegue il login di un utente
|
|
||||||
*/
|
|
||||||
async function login(username, password) {
|
async function login(username, password) {
|
||||||
const result = await query('SELECT id, username, password_hash, active, created_at FROM users WHERE username = $1', [username]);
|
const { rows } = await query(
|
||||||
if (result.rows.length === 0) {
|
'SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1',
|
||||||
throw new Error('No user matched')
|
[username]
|
||||||
}
|
);
|
||||||
|
if (!rows.length) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide');
|
||||||
|
|
||||||
const user = result.rows[0];
|
const user = rows[0];
|
||||||
const isValid = await security.verifyPassword(password, user.password_hash);
|
if (!user.is_active) throw new AuthError('ACCOUNT_INACTIVE', 'Account disattivato');
|
||||||
|
|
||||||
if (!isValid) {
|
const ok = await security.verifyPassword(password, user.password_hash);
|
||||||
throw new Error('Password mismatch')
|
if (!ok) throw new AuthError('INVALID_CREDENTIALS', 'Credenziali non valide');
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return { id: user.id, username: user.username, created_at: user.created_at };
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
created: user.created_at
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ─── SESSIONI ───────────────────────────────────────────────────────
|
||||||
* Esegue il logout di un utente
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
async function logout(sessionID) {
|
|
||||||
if (!sessionID) {
|
|
||||||
throw new Error('no sessio id passed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await query('UPDATE sessions SET is_revoked = TRUE WHERE id = $1', [sessionID]);
|
async function createSession(userId, userAgent, ip) {
|
||||||
return result.rowCount > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Crea una nuova sessione per un utente che ha appaena eseguito il login
|
|
||||||
*/
|
|
||||||
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function revokeSession(sessionId, userId) {
|
||||||
* Valida una sessione
|
if (userId) {
|
||||||
*/
|
const r = await query(
|
||||||
async function validateSession(token) {
|
'UPDATE sessions SET is_revoked = TRUE WHERE id = $1 AND user_id = $2 AND is_revoked = FALSE',
|
||||||
const parsed = security.parseSessionToken(token);
|
[sessionId, userId]
|
||||||
|
);
|
||||||
if (!parsed) {
|
return r.rowCount > 0;
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -61,10 +61,13 @@ const authRateLimit = createRateLimiter(RATE_LIMIT_AUTH_MAX);
|
|||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.setHeader('X-Frame-Options', 'DENY');
|
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('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
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');
|
res.removeHeader('X-Powered-By');
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
@@ -80,6 +83,7 @@ app.use(parser());
|
|||||||
// ─── STATIC FILES ───────────────────────────────────────────────────
|
// ─── STATIC FILES ───────────────────────────────────────────────────
|
||||||
const staticFolder = path.join(__dirname, 'static');
|
const staticFolder = path.join(__dirname, 'static');
|
||||||
app.use('/static', express.static(staticFolder));
|
app.use('/static', express.static(staticFolder));
|
||||||
|
app.use('/api/static', express.static(staticFolder));
|
||||||
|
|
||||||
// ─── NUNJUCKS TEMPLATES ─────────────────────────────────────────────
|
// ─── NUNJUCKS TEMPLATES ─────────────────────────────────────────────
|
||||||
const templatesFolder = path.join(__dirname, 'templates');
|
const templatesFolder = path.join(__dirname, 'templates');
|
||||||
@@ -112,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
|
||||||
@@ -133,7 +134,7 @@ app.use((req, res) => {
|
|||||||
|
|
||||||
// ─── ERROR HANDLER GLOBALE ──────────────────────────────────────────
|
// ─── ERROR HANDLER GLOBALE ──────────────────────────────────────────
|
||||||
app.use((err, req, res, _next) => {
|
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' });
|
res.status(500).json({ error: 'Errore interno del server' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
const jwt = require('../tools/jwt');
|
const jwt = require('../tools/jwt');
|
||||||
|
const { validateSession } = require('../core/auth.core');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware di autenticazione per utenti finali.
|
* Middleware: richiede un utente autenticato valido.
|
||||||
* Verifica il JWT dal cookie 'auth_token' o dall'header 'Authorization: Bearer <token>'.
|
* Legge il token dal cookie `auth_token` o dall'header `Authorization: Bearer <token>`.
|
||||||
*
|
*
|
||||||
* 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) => {
|
module.exports = async function userAuth(req, res, next) {
|
||||||
const token = (req.cookies && req.cookies.auth_token) || jwt.getToken(req.headers['authorization']);
|
const token = (req.cookies && req.cookies.auth_token) || jwt.bearer(req.headers.authorization);
|
||||||
|
|
||||||
if (!token || typeof token !== 'string') {
|
const unauthorized = (reason) => {
|
||||||
return res.status(401).json({ error: 'Accesso negato: token mancante' });
|
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
|
const v = jwt.verify(token);
|
||||||
if (token.length > 2048) {
|
if (!v.valid) return unauthorized(`token_${v.reason}`);
|
||||||
return res.status(400).json({ error: 'Token non valido' });
|
|
||||||
|
try {
|
||||||
|
await validateSession(v.payload.session_id);
|
||||||
|
} catch (err) {
|
||||||
|
return unauthorized(err.code || 'session_invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
const verified = jwt.verifyToken(token);
|
req.user = v.payload;
|
||||||
if (!verified.valid) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: 'Sessione non valida o scaduta',
|
|
||||||
reason: verified.reason
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
req.user = verified.payload;
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = userAuth;
|
|
||||||
|
|||||||
@@ -4,120 +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';
|
||||||
|
|
||||||
// Validazione input
|
|
||||||
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) => {
|
router.post('/register', async (req, res) => {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body || {};
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password || typeof username !== 'string' || typeof password !== 'string') {
|
||||||
return res.status(400).json({ error: 'Username e password richiesti' });
|
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)) {
|
if (!USERNAME_REGEX.test(username)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, error: 'invalid_username' });
|
||||||
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({
|
|
||||||
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);
|
||||||
res.status(201).end();
|
return res.status(201).json({ success: true, user });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AUTH] Register failed:', err.message);
|
if (err.code === 'USER_EXISTS') {
|
||||||
const status = err.message === 'User already exists' ? 409 : 500;
|
return res.status(409).json({ success: false, error: 'user_exists' });
|
||||||
res.status(status).json({ error: err.message === 'User already exists' ? err.message : 'Errore interno' });
|
}
|
||||||
|
console.error('[AUTH] register:', err.message);
|
||||||
|
return res.status(500).json({ success: false, error: 'internal' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── POST /login ───────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
const { username, password, redirect } = req.body;
|
const { username, password, redirect, _csrf } = req.body || {};
|
||||||
|
|
||||||
// Validazione base
|
// Verifica CSRF token
|
||||||
if (!username || !password || typeof username !== 'string' || typeof password !== 'string') {
|
const csrfCookie = req.cookies && req.cookies._csrf;
|
||||||
return res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' });
|
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 || !password || typeof username !== 'string' || typeof password !== 'string'
|
||||||
if (username.length > 50 || password.length > PASSWORD_MAX_LENGTH) {
|
|| username.length > 50 || password.length > MAX_PASSWORD) {
|
||||||
return res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' });
|
return res.status(400).json({
|
||||||
|
success: false, error: 'invalid_credentials', message: 'Credenziali non valide'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validazione redirect URL per prevenire open redirect attacks
|
const safeRedirect = resolveSafeRedirect(redirect);
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
return res.status(200).json({
|
||||||
cookieOptions.domain = COOKIE_DOMAIN;
|
success: true,
|
||||||
}
|
redirect_url: safeRedirect,
|
||||||
|
message: 'Login effettuato'
|
||||||
res.cookie('auth_token', token, cookieOptions);
|
});
|
||||||
|
|
||||||
const destination = redirect || CONSOLE_URL;
|
|
||||||
res.redirect(destination);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AUTH] Login failed:', err.message);
|
if (err.code === 'INVALID_CREDENTIALS') {
|
||||||
// Mai rivelare se è l'utente o la password ad essere sbagliati
|
return res.status(401).json({
|
||||||
res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' });
|
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) => {
|
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;
|
// 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);
|
return res.status(200).json({ valid: true, user: v.payload });
|
||||||
res.redirect('/login');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
const router = require('express').Router();
|
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) => {
|
router.get('/login', (req, res) => {
|
||||||
const redirect = req.query.redirect || '';
|
const redirect = typeof req.query.redirect === 'string' ? req.query.redirect : '';
|
||||||
res.render('loginpage', { error: null, 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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const userAuth = require('../../middlewares/user.security');
|
|||||||
router.use(userAuth);
|
router.use(userAuth);
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
res.render('sessions');
|
res.render('sessions', { user: req.user });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
|
const userAuth = require('../../middlewares/user.security');
|
||||||
|
|
||||||
|
router.use(userAuth);
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
res.render('user');
|
res.render('user', { user: req.user });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
BIN
auth/src/static/font/sans-flex.ttf
Normal file
BIN
auth/src/static/font/sans-flex.ttf
Normal file
Binary file not shown.
@@ -15,7 +15,8 @@
|
|||||||
--header-border: #e2e8f0;
|
--header-border: #e2e8f0;
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
--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-md: 8px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
}
|
}
|
||||||
@@ -26,21 +27,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Normal';
|
font-family: "Normal";
|
||||||
src: url('../font/Quicksand-VariableFont_wght.ttf');
|
src: url("../font/sans-flex.ttf");
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Bold';
|
font-family: "Bold";
|
||||||
src: url('../font/Quicksand-VariableFont_wght.ttf');
|
src: url("../font/sans-flex.ttf");
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: "Normal", Arial;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@ button {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'Bold', inherit;
|
font-family: "Bold", inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1);
|
transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -79,13 +80,11 @@ button.prominent:hover {
|
|||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
button.prominent:active {
|
button.prominent:active {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* INFO PANEL */
|
/* INFO PANEL */
|
||||||
|
|
||||||
.info-panel {
|
.info-panel {
|
||||||
@@ -111,9 +110,6 @@ button.prominent:active {
|
|||||||
transition: transform 0.12s ease;
|
transition: transform 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* GRID & CARD ITEMS */
|
/* GRID & CARD ITEMS */
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -132,7 +128,9 @@ button.prominent:active {
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text-primary);
|
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 {
|
.card h3 {
|
||||||
@@ -158,10 +156,6 @@ button.prominent:active {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* HEADER */
|
/* HEADER */
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -179,7 +173,6 @@ button.prominent:active {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
@@ -198,4 +191,4 @@ button.prominent:active {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
padding-inline: 5px;
|
padding-inline: 5px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,58 +5,46 @@ const config = {
|
|||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
port: process.env.DB_PORT,
|
port: process.env.DB_PORT,
|
||||||
|
database: process.env.USERS_DB || process.env.DB_NAME,
|
||||||
max: 10,
|
max: 10,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
connectionTimeoutMillis: 5000
|
connectionTimeoutMillis: 5000
|
||||||
}
|
};
|
||||||
|
|
||||||
const pool = new Pool({ ...config, database: process.env.USERS_DB });
|
const pool = new Pool(config);
|
||||||
|
|
||||||
pool.on('error', (err) => {
|
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<Object>} Query result
|
|
||||||
*/
|
|
||||||
async function query(text, params) {
|
async function query(text, params) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const result = await pool.query(text, params);
|
try {
|
||||||
const duration = Date.now() - start;
|
const result = await pool.query(text, params);
|
||||||
|
const duration = Date.now() - start;
|
||||||
if (duration > 100) {
|
if (duration > 100) {
|
||||||
console.warn(`[DB] Slow query (${duration}ms):`, text.substring(0, 80));
|
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<Object>} Pool client
|
|
||||||
*/
|
|
||||||
async function getClient() {
|
async function getClient() {
|
||||||
return await pool.connect();
|
return await pool.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize database and ensure tables exist
|
|
||||||
*/
|
|
||||||
async function initDb() {
|
async function initDb() {
|
||||||
// Test connection
|
|
||||||
await pool.query('SELECT NOW()');
|
await pool.query('SELECT NOW()');
|
||||||
// Ensure pgcrypto extension (provides gen_random_uuid)
|
|
||||||
// Note: creating extensions requires proper DB permissions (usually superuser in PG)
|
|
||||||
try {
|
try {
|
||||||
await pool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto;`);
|
await pool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto;`);
|
||||||
} catch (err) {
|
} 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(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -76,7 +64,7 @@ async function initDb() {
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
session_code VARCHAR(64) NOT NULL,
|
session_code VARCHAR(64) NOT NULL,
|
||||||
encoded_username TEXT NOT NULL,
|
encoded_username TEXT NOT NULL DEFAULT '',
|
||||||
ip_address INET,
|
ip_address INET,
|
||||||
user_agent TEXT,
|
user_agent TEXT,
|
||||||
browser VARCHAR(100),
|
browser VARCHAR(100),
|
||||||
@@ -89,9 +77,6 @@ async function initDb() {
|
|||||||
is_revoked BOOLEAN DEFAULT FALSE
|
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_code ON sessions(session_code);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||||
`);
|
`);
|
||||||
@@ -101,7 +86,7 @@ async function checkPostgres() {
|
|||||||
try {
|
try {
|
||||||
await pool.query('SELECT NOW()');
|
await pool.query('SELECT NOW()');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,108 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
<link rel="stylesheet" href="../static/style/style.css">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="stylesheet" href="../static/style/login.css" </head>
|
<title>Login — Console MEB</title>
|
||||||
|
<link rel="stylesheet" href="/static/style/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/style/login.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="login">
|
<div class="login">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Accedi alla <span class="prominent-title">Console MEB</span></h1>
|
<h1>Accedi alla <span class="prominent-title">Console MEB</span></h1>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<p class="error">{{ error }}</p>
|
<p class="error" id="errorMessage">{{ error }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form action="/api/auth/login" method="post">
|
<form id="loginForm">
|
||||||
<input type="hidden" name="redirect" value="{{ redirect }}">
|
<input type="hidden" id="redirect" name="redirect" value="{{ redirect }}">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input type="text" id="username" name="username" required>
|
<input type="text" id="username" name="username" required autocomplete="username">
|
||||||
</div>
|
</div>
|
||||||
<div class="group">
|
<div class="group">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input type="password" id="password" name="password" required>
|
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit">Login</button>
|
<button type="submit" class="prominent" id="submitBtn">Login</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Accesso in corso...';
|
||||||
|
if (errorMessage) errorMessage.style.display = 'none';
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: formData.get('username'),
|
||||||
|
password: formData.get('password'),
|
||||||
|
redirect: formData.get('redirect'),
|
||||||
|
_csrf: formData.get('_csrf')
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (response.ok && data.success && data.redirect_url) {
|
||||||
|
window.location.href = data.redirect_url;
|
||||||
|
} else {
|
||||||
|
const errorMsg = data.message || 'Errore durante il login';
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
errorMessage.textContent = errorMsg;
|
||||||
|
errorMessage.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
alert(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Login';
|
||||||
|
|
||||||
|
// Ricarica la pagina per ottenere un nuovo CSRF token
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = 'Errore di connessione. Riprova più tardi.';
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
errorMessage.textContent = errorMsg;
|
||||||
|
errorMessage.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
alert(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Login';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1 +1,109 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sessioni — Console MEB</title>
|
||||||
|
<link rel="stylesheet" href="/static/style/style.css">
|
||||||
|
<style>
|
||||||
|
main { padding: 24px 30px; }
|
||||||
|
main h2 { font-size: 1.1rem; margin-bottom: 20px; color: var(--text-secondary); font-weight: 500; }
|
||||||
|
.session-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.session-card .info h3 { font-size: 0.95rem; margin-bottom: 4px; }
|
||||||
|
.session-card .info p { font-size: 0.8rem; color: var(--text-secondary); margin: 2px 0; }
|
||||||
|
.session-card button { font-size: 0.8rem; padding: 6px 14px; color: #dc2626; border-color: #fca5a5; }
|
||||||
|
.session-card button:hover { background-color: #fef2f2; border-color: #dc2626; color: #dc2626; }
|
||||||
|
#loading { color: var(--text-tertiary); font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Console MEB</h1>
|
||||||
|
<div class="profile">
|
||||||
|
<p>{{ user.username }}</p>
|
||||||
|
<form action="/api/auth/logout" method="post">
|
||||||
|
<button type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h2>Sessioni attive</h2>
|
||||||
|
<div id="sessions-container">
|
||||||
|
<p id="loading">Caricamento...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.appendChild(document.createTextNode(str || ''));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return 'N/D';
|
||||||
|
return new Date(iso).toLocaleString('it-IT', {
|
||||||
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessions() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sessions');
|
||||||
|
if (res.status === 401) { window.location.href = '/login'; return; }
|
||||||
|
if (!res.ok) throw new Error('Network error');
|
||||||
|
|
||||||
|
const sessions = await res.json();
|
||||||
|
const container = document.getElementById('sessions-container');
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
container.innerHTML = '<p style="color:var(--text-tertiary)">Nessuna sessione attiva.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = sessions.map(s => `
|
||||||
|
<div class="session-card" id="session-${escapeHtml(s.id)}">
|
||||||
|
<div class="info">
|
||||||
|
<h3>${escapeHtml(s.browser || 'Browser sconosciuto')} su ${escapeHtml(s.os || 'OS sconosciuto')}</h3>
|
||||||
|
<p>${escapeHtml(s.device_type || '')}${s.ip_address ? ' — ' + escapeHtml(s.ip_address) : ''}</p>
|
||||||
|
<p>Ultima attività: ${formatDate(s.last_active)}</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="revokeSession('${escapeHtml(s.id)}')">Revoca</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch {
|
||||||
|
document.getElementById('sessions-container').innerHTML =
|
||||||
|
'<p style="color:#dc2626">Errore nel caricamento delle sessioni.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeSession(id) {
|
||||||
|
if (!confirm('Revocare questa sessione?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||||
|
if (res.status === 401) { window.location.href = '/login'; return; }
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const el = document.getElementById('session-' + id);
|
||||||
|
if (el) el.remove();
|
||||||
|
} catch {
|
||||||
|
alert('Errore durante la revoca della sessione.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSessions();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -1 +1,87 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Profilo — Console MEB</title>
|
||||||
|
<link rel="stylesheet" href="/static/style/style.css">
|
||||||
|
<style>
|
||||||
|
main { padding: 24px 30px; max-width: 600px; }
|
||||||
|
main h2 { font-size: 1.1rem; margin-bottom: 20px; color: var(--text-secondary); font-weight: 500; }
|
||||||
|
.field { margin-bottom: 20px; }
|
||||||
|
.field label { display: block; font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; }
|
||||||
|
.field p { font-size: 0.95rem; padding: 10px 14px; border: 1px solid var(--header-border); border-radius: var(--radius-md); }
|
||||||
|
.field-empty { color: var(--text-tertiary); font-style: italic; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Console MEB</h1>
|
||||||
|
<div class="profile">
|
||||||
|
<p id="username-label">{{ user.username }}</p>
|
||||||
|
<form action="/api/auth/logout" method="post">
|
||||||
|
<button type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h2>Profilo utente</h2>
|
||||||
|
<div id="user-info">
|
||||||
|
<p style="color:var(--text-tertiary)">Caricamento...</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.appendChild(document.createTextNode(str || ''));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return 'N/D';
|
||||||
|
return new Date(iso).toLocaleDateString('it-IT', {
|
||||||
|
day: 'numeric', month: 'long', year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUser() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users/me');
|
||||||
|
if (res.status === 401) { window.location.href = '/login'; return; }
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
|
const user = await res.json();
|
||||||
|
document.getElementById('username-label').textContent = user.username;
|
||||||
|
document.getElementById('user-info').innerHTML = `
|
||||||
|
<div class="field">
|
||||||
|
<label>Username</label>
|
||||||
|
<p>${escapeHtml(user.username)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Account creato il</label>
|
||||||
|
<p>${formatDate(user.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Telegram ID</label>
|
||||||
|
<p>${user.telegram_id ? escapeHtml(user.telegram_id) : '<span class="field-empty">Non configurato</span>'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Sessioni</label>
|
||||||
|
<p><a href="/sessions">Gestisci sessioni →</a></p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch {
|
||||||
|
document.getElementById('user-info').innerHTML =
|
||||||
|
'<p style="color:#dc2626">Errore nel caricamento del profilo.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUser();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -1,70 +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;
|
||||||
|
|
||||||
//TODO: valutare se modificare in return null per accettare solo metodo Bearer Authorization Token,
|
|
||||||
return header;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { generateToken, verifyToken, getToken };
|
module.exports = { sign, verify, bearer };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
async function hashPassword(password) {
|
||||||
* Genera un hash di una password
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
* @param {string} password - Password da hashare
|
|
||||||
* @returns {string} - Hash della password
|
|
||||||
*/
|
|
||||||
function hashPassword(password) {
|
|
||||||
return bcrypt.hashSync(password, saltRounds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function verifyPassword(password, hash) {
|
||||||
* Verifica una password
|
return bcrypt.compare(password, hash);
|
||||||
* @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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
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
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
74
console/src/middlewares/auth.js
Normal file
74
console/src/middlewares/auth.js
Normal 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 };
|
||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
- "3006:3006"
|
- "3006:3006"
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.routers.auth.entrypoints=websecure"
|
||||||
- "traefik.http.services.auth.loadbalancer.server.port=3006"
|
- "traefik.http.services.auth.loadbalancer.server.port=3006"
|
||||||
- "traefik.docker.network=meb-public"
|
- "traefik.docker.network=meb-public"
|
||||||
@@ -46,7 +46,7 @@ services:
|
|||||||
- meb-private
|
- meb-private
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.routers.api.entrypoints=websecure"
|
||||||
- "traefik.http.services.api.loadbalancer.server.port=3003"
|
- "traefik.http.services.api.loadbalancer.server.port=3003"
|
||||||
- "traefik.http.routers.api.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.api.tls.certresolver=letsencrypt"
|
||||||
@@ -68,7 +68,7 @@ services:
|
|||||||
- meb-private
|
- meb-private
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.routers.console.entrypoints=websecure"
|
||||||
- "traefik.http.services.console.loadbalancer.server.port=3004"
|
- "traefik.http.services.console.loadbalancer.server.port=3004"
|
||||||
- "traefik.http.routers.console.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.console.tls.certresolver=letsencrypt"
|
||||||
@@ -90,7 +90,7 @@ services:
|
|||||||
- meb-public
|
- meb-public
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.routers.realtime.entrypoints=websecure"
|
||||||
- "traefik.http.services.realtime.loadbalancer.server.port=3000"
|
- "traefik.http.services.realtime.loadbalancer.server.port=3000"
|
||||||
- "traefik.http.routers.realtime.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.realtime.tls.certresolver=letsencrypt"
|
||||||
@@ -115,7 +115,7 @@ services:
|
|||||||
- meb-public
|
- meb-public
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "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.routers.ml.entrypoints=websecure"
|
||||||
- "traefik.http.services.ml.loadbalancer.server.port=8000"
|
- "traefik.http.services.ml.loadbalancer.server.port=8000"
|
||||||
- "traefik.http.routers.ml.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.ml.tls.certresolver=letsencrypt"
|
||||||
@@ -139,7 +139,7 @@ services:
|
|||||||
# - meb-internal
|
# - meb-internal
|
||||||
# labels:
|
# labels:
|
||||||
# - "traefik.enable=true"
|
# - "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.routers.marine.entrypoints=web"
|
||||||
# - "traefik.http.services.marine.loadbalancer.server.port=8001"
|
# - "traefik.http.services.marine.loadbalancer.server.port=8001"
|
||||||
# - "traefik.docker.network=meb-proxy-net"
|
# - "traefik.docker.network=meb-proxy-net"
|
||||||
@@ -155,8 +155,8 @@ services:
|
|||||||
# environment:
|
# environment:
|
||||||
# - DATABASE_URL=postgresql://meb:meb@meb-postgres:5432/circuits
|
# - DATABASE_URL=postgresql://meb:meb@meb-postgres:5432/circuits
|
||||||
# - AUTH_SERVICE_URL=http://auth:3001
|
# - AUTH_SERVICE_URL=http://auth:3001
|
||||||
# - AUTH_URL=http://auth.${URL_DOMAIN:-localhost}
|
# - AUTH_URL=http://auth.${DOMAIN:-localhost}
|
||||||
# - API_URL=http://api.${URL_DOMAIN:-localhost}
|
# - API_URL=http://api.${DOMAIN:-localhost}
|
||||||
# - NODE_ENV=${NODE_ENV:-development}
|
# - NODE_ENV=${NODE_ENV:-development}
|
||||||
# volumes:
|
# volumes:
|
||||||
# - ./circuits/src:/app/src
|
# - ./circuits/src:/app/src
|
||||||
@@ -173,7 +173,7 @@ services:
|
|||||||
# - meb-internal
|
# - meb-internal
|
||||||
# labels:
|
# labels:
|
||||||
# - "traefik.enable=true"
|
# - "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.routers.circuits.entrypoints=web"
|
||||||
# - "traefik.http.services.circuits.loadbalancer.server.port=3005"
|
# - "traefik.http.services.circuits.loadbalancer.server.port=3005"
|
||||||
# - "traefik.docker.network=meb-proxy-net"
|
# - "traefik.docker.network=meb-proxy-net"
|
||||||
|
|||||||
85
ml/core/auth.py
Normal file
85
ml/core/auth.py
Normal 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
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
fastapi
|
fastapi
|
||||||
uvicorn
|
uvicorn
|
||||||
|
PyJWT
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
51
realtime/src/middlewares/auth.js
Normal file
51
realtime/src/middlewares/auth.js
Normal 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 };
|
||||||
Reference in New Issue
Block a user