feat: initialize microservice architecture with auth, api, realtime, copernicus, ml, and console modules

This commit is contained in:
Giuseppe Raffa
2026-03-28 15:29:34 +01:00
commit bcfce32adb
89 changed files with 12025 additions and 0 deletions

120
auth/src/core/auth.core.js Normal file
View File

@@ -0,0 +1,120 @@
const query = require('../storage/database').query;
const track = require('../tools/tracking')
const { v4: uuid } = require('uuid');
const security = require('../tools/security')
/**
* Registra un nuovo utente
*/
async function register(username, password) {
const userExists = await query('SELECT id FROM users WHERE username = $1', [username]);
if (userExists.rows.length > 0) {
throw new Error('User already exists');
}
const hashedPassword = security.hashPassword(password);
const id = uuid();
await query('INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)', [id, username, hashedPassword]);
return {
success: true,
user: {
id,
username
}
};
}
/**
* Esegue il login di un utente
*/
async function login(username, password) {
const result = await query('SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1', [username]);
if (result.rows.length === 0) {
throw new Error('No user matched')
}
const user = result.rows[0];
const isValid = await security.verifyPassword(password, user.password_hash);
if (!isValid) {
throw new Error('Password mismatch')
}
return {
id: user.id,
username: user.username,
created: user.created_at
}
}
/**
* 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]);
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 sessionCode = security.generateSessionCode();
const metadata = track.getBasicMetadata(userAgent);
await query(
`INSERT INTO sessions (id, user_id, session_code, encoded_username, ip_address, user_agent, browser, os, device_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[id, userId, sessionCode, '', ip, userAgent, metadata.browser, metadata.os, metadata.device_type]
);
return { id, sessionCode };
}
/**
* Valida una sessione
*/
async function validateSession(token) {
const parsed = security.parseSessionToken(token);
if (!parsed) {
throw new Error('Invalid token format');
}
const { code, username } = parsed;
const result = await query('SELECT s.id as session_id, s.user_id, u.username, u.is_active, u.created_at FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.session_code = $1 AND s.is_revoked = FALSE', [code]);
if (result.rows.length === 0) {
throw new Error('Session not found or revoked')
}
const session = result.rows[0];
if (session.username !== username) {
throw new Error('Session user mismatch');
}
if (!session.is_active) {
throw new Error('Session is not active');
}
}
module.exports = {
register,
login,
logout,
newSession,
validateSession
}

View File

@@ -0,0 +1,56 @@
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
};

49
auth/src/index.js Normal file
View File

@@ -0,0 +1,49 @@
const express = require('express');
const nunjucks = require('nunjucks');
const path = require('path');
const parser = require('cookie-parser');
const database = require('./storage/database');
const app = express();
const PORT = process.env.PORT || 3006;
const version = process.env.VERSION;
const vBuild = process.env.VERSION_BUILD;
const vState = process.env.VERSION_STATE;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(parser());
// Static files
const staticFolder = path.join(__dirname, 'static');
app.use('/static', express.static(staticFolder));
// Nunjucks templates
const templatesFolder = path.join(__dirname, 'templates');
nunjucks.configure(templatesFolder, {
autoescape: true,
express: app,
noCache: true,
watch: false
});
app.set('views', templatesFolder);
app.set('view engine', 'html');
// Routes
const authRoutes = require('./routes/auth');
app.use('/', authRoutes);
// Startup
async function start() {
await database.initDb();
app.listen(PORT, () => {
console.log(`[AUTH] Started on port ${PORT}`);
});
}
start().catch(err => {
console.error('[AUTH] Failed to start:', err);
process.exit(1);
});

97
auth/src/routes/auth.js Normal file
View File

@@ -0,0 +1,97 @@
const router = require('express').Router();
const auth = require('../core/auth.core');
const jwt = require('../tools/jwt');
const version = process.env.VERSION;
const vBuild = process.env.VERSION_BUILD;
const vState = process.env.VERSION_STATE;
const CONSOLE_URL = process.env.CONSOLE_URL || 'http://localhost:3004';
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
router.get('/health', (req, res) => {
res.json({
status: 'ok',
service: 'auth',
version: version,
build_number: vBuild,
version_state: vState
});
});
router.post('/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username e password richiesti' });
}
try {
await auth.register(username, password);
res.status(201).end();
} catch (err) {
console.error('[AUTH] Register failed:', err.message);
const status = err.message === 'User already exists' ? 409 : 500;
res.status(status).json({ error: err.message });
}
});
router.get('/login', (req, res) => {
const redirect = req.query.redirect || '';
res.render('loginpage', { error: null, redirect });
});
router.post('/login', async (req, res) => {
const { username, password, redirect } = req.body;
try {
const user = await auth.login(username, password);
const session = await auth.newSession(user.id, req.headers['user-agent'], req.ip);
const token = jwt.generateToken(user, session.id);
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 giorni
};
if (COOKIE_DOMAIN) {
cookieOptions.domain = COOKIE_DOMAIN;
}
res.cookie('auth_token', token, cookieOptions);
// Redirect alla pagina da cui l'utente e' arrivato, o alla console
const destination = redirect || CONSOLE_URL;
res.redirect(destination);
} catch (err) {
console.error('[AUTH] Login failed:', err.message, err.stack);
res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' });
}
});
router.post('/logout', async (req, res) => {
const token = req.cookies && req.cookies.auth_token;
if (token) {
try {
const verified = jwt.verifyToken(token);
if (verified.valid) {
await auth.logout(verified.payload.session_id);
}
} catch (err) {
console.error('[AUTH] Logout error:', err.message);
}
}
const clearOptions = { httpOnly: true, sameSite: 'lax' };
if (COOKIE_DOMAIN) {
clearOptions.domain = COOKIE_DOMAIN;
}
res.clearCookie('auth_token', clearOptions);
res.redirect('/login');
});
module.exports = router;

View File

2
auth/src/routes/users.js Normal file
View File

@@ -0,0 +1,2 @@
const router = require('express').Router();

BIN
auth/src/static/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,99 @@
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
user-select: none;
}
/* Fix size even if the title gets bigger when hovered*/
.login {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border-radius: 30px;
padding: 50px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
span.prominent-title {
color: var(--accent-color);
font-weight: 700;
transition: all 0.3s ease;
}
span.prominent-title:hover {
font-size: 26px;
/* Animated color gradient transitioning with all the colors of the rainbow, animated when hovere*/
background: linear-gradient(90deg, #002bff, #7a00ff, #ff00c8);
background-size: 200% 200%;
animation: gradient 5s ease infinite;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.login header {
padding: 40px;
font-size: 24px;
font-weight: 700;
}
.login form {
padding: 20px;
align-items: center;
align-self: center;
align-content: center;
}
.login form .group {
display: flex;
flex-direction: column;
justify-content: left;
align-items: left;
margin-bottom: 20px;
}
.login form .group label {
font-size: 10px;
font-weight: 600;
align-items: left;
color: var(--text-secondary);
margin-bottom: 5px;
}
.login form .group input {
padding: 10px;
border-radius: 15px;
border: 1px solid #ccc;
font-size: 14px;
font-weight: 600;
margin-bottom: 5px;
width: 100%;
transition: all 0.3s ease;
}
.login form .group input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}

View File

@@ -0,0 +1,201 @@
:root {
--accent-color: #2563eb;
--accent-hover: #1d4ed8;
--accent-light: #eff6ff;
--accent-border: #bfdbfe;
--text-primary: #0f172a;
--text-secondary: #4755698f;
--text-tertiary: #94a3b8c0;
--surface: #f8fafc;
--header-bg: rgba(255, 255, 255, 0.85);
/* For Glassmorphism */
--header-border: #e2e8f0;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--radius-md: 8px;
--radius-lg: 12px;
}
* {
margin: 0;
padding: 0;
}
@font-face {
font-family: 'Normal';
src: url('../font/Quicksand-VariableFont_wght.ttf');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Bold';
src: url('../font/Quicksand-VariableFont_wght.ttf');
font-weight: 700;
font-style: normal;
}
body {
font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
button {
padding: 10px 24px;
border-radius: var(--radius-lg);
border: 1px solid var(--header-border);
background-color: var(--bg-surface);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
font-family: 'Bold', inherit;
cursor: pointer;
transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1);
white-space: nowrap;
flex-shrink: 0;
}
button:hover {
background-color: var(--accent-light);
color: var(--accent-color);
border-color: var(--accent-border);
}
button.prominent {
background-color: var(--accent-color);
color: #ffffff;
border: 1px solid transparent;
box-shadow: var(--shadow-sm);
}
button.prominent:hover {
background-color: var(--accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
button.prominent:active {
transform: translateY(1px);
box-shadow: var(--shadow-sm);
}
/* INFO PANEL */
.info-panel {
display: block;
text-align: center;
align-content: center;
padding: 60px 20px;
user-select: none;
}
.info-panel h3 {
margin-bottom: 10px;
}
.info-panel p {
margin-bottom: 25px;
}
.info-panel .icon {
font-size: 48px;
margin-bottom: 20px;
align-self: center;
transition: transform 0.12s ease;
}
/* GRID & CARD ITEMS */
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
margin-inline: 30px;
}
.card {
display: flex;
gap: 0.75rem;
align-items: center;
padding: 1rem;
border: 1px solid var(--header-border);
border-radius: 20px;
text-decoration: none;
color: var(--text-primary);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.card h3 {
margin: 0;
font-size: 1rem;
text-align: left;
}
.card p {
margin: 0.25rem 0 0;
color: var(--text-secondary);
opacity: 0.4;
font-size: 0.8rem;
text-align: left;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 30px #bfdbfe30;
}
.card.standalone {
grid-column: 1 / -1;
}
/* HEADER */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background-color: var(--header-bg);
border-bottom: 1px solid var(--header-border);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 10;
user-select: none;
}
.header h1 {
color: var(--text-primary);
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.025em;
}
.header .profile {
display: flex;
align-items: center;
gap: 16px;
}
.header .profile p {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
padding-inline: 5px;
}

View File

@@ -0,0 +1,105 @@
const { Pool } = require('pg');
const config = {
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
host: process.env.DB_HOST,
port: process.env.DB_PORT,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000
}
const pool = new Pool({ ...config, database: process.env.DB_NAME });
pool.on('error', (err) => {
console.error('Error in database', err);
});
/**
* 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) {
const start = Date.now();
const result = await pool.query(text, params);
const duration = Date.now() - start;
if (duration > 100) {
console.warn(`[DB] Slow query (${duration}ms):`, text.substring(0, 80));
}
return result;
}
/**
* Get a client from pool for transactions
* @returns {Promise<Object>} Pool client
*/
async function getClient() {
return await pool.connect();
}
/**
* Initialize database and ensure tables exist
*/
async function initDb() {
// Test connection
await pool.query('SELECT NOW()');
// Ensure pgcrypto extension (provides gen_random_uuid)
// Note: creating extensions requires proper DB permissions (usually superuser in PG)
try {
await pool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto;`);
} catch (err) {
console.warn('[DB] Could not create pgcrypto extension (may require superuser):', err.message);
}
// Ensure tables exist (UUID default generated by DB)
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
telegram_id VARCHAR(50) UNIQUE
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_code VARCHAR(64) NOT NULL,
encoded_username TEXT NOT NULL,
ip_address INET,
user_agent TEXT,
browser VARCHAR(100),
os VARCHAR(100),
device_type VARCHAR(50),
location_country VARCHAR(100),
location_city VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW(),
last_active TIMESTAMP DEFAULT NOW(),
is_revoked BOOLEAN DEFAULT FALSE
);
-- Altera colonna in base al nuovo standard token 32 byte - 64 url chars
ALTER TABLE sessions ALTER COLUMN session_code TYPE VARCHAR(64);
CREATE INDEX IF NOT EXISTS idx_sessions_code ON sessions(session_code);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
`);
}
module.exports = {
pool,
query,
getClient,
initDb
};

36
auth/src/storage/redis.js Normal file
View File

@@ -0,0 +1,36 @@
const Redis = require('ioredis');
const redis = new Redis({
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT),
maxRetriesPerRequest: 3,
lazyConnect: true,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
}
})
redis.on('error', (error) => {
console.error('redis error', error);
})
redis.on('connect', () => {})
redis.on('reconnecting', () => {
console.log('reconnecting to redis')
})
async function configure() {
try {
await redis.connect();
await redis.ping();
} catch (err) {
console.error('Redis error', err);
}
}
function connected() {
return redis.status === 'ready';
}
module.exports = { redis, configure, connected };

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="../static/style/style.css">
<link rel="stylesheet" href="../static/style/login.css" </head>
<body>
<div class="container">
<div class="login">
<div class="header">
<h1>Accedi alla <span class="prominent-title">Console MEB</span></h1>
</div>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form action="/login" method="post">
<input type="hidden" name="redirect" value="{{ redirect }}">
<div class="group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Login</button>
</form>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

70
auth/src/tools/jwt.js Normal file
View File

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

View File

@@ -0,0 +1,54 @@
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const saltRounds = 12;
/**
* Genera un hash di una password
* @param {string} password - Password da hashare
* @returns {string} - Hash della password
*/
function hashPassword(password) {
return bcrypt.hashSync(password, saltRounds);
}
/**
* Verifica una password
* @param {string} password - Password da verificare
* @param {string} hash - Hash della password
* @returns {boolean} - True se la password è corretta, false altrimenti
*/
function verifyPassword(password, hash) {
return bcrypt.compareSync(password, hash);
}
/**
* 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');
}
/**
* Parse a session token
* @param {string} token
* @returns {string|null} The session token itself if valid
*/
function parseSessionToken(token) {
if (!token || typeof token !== 'string' || token.length < 32 || token.length > 64) {
return null;
}
return token;
}
module.exports = {
hashPassword,
verifyPassword,
generateSessionCode,
parseSessionToken
};

View File

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