feat: initialize microservice architecture with auth, api, realtime, copernicus, ml, and console modules
This commit is contained in:
13
auth/Dockerfile
Normal file
13
auth/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
|
||||
COPY src ./src
|
||||
|
||||
EXPOSE 3006
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
1402
auth/package-lock.json
generated
Normal file
1402
auth/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
auth/package.json
Normal file
22
auth/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "meb-auth-service",
|
||||
"version": "1.2.0",
|
||||
"description": "Servizio di Sicurezza e Autenticazione per il server MEB",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"generate-docs": "node -e \"console.log(JSON.stringify(require('./swagger-specs-logic')))\" > openapi.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.2.1",
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"nunjucks": "^3.2.4",
|
||||
"pg": "^8.20.0",
|
||||
"ua-parser-js": "^2.0.9",
|
||||
"uuid": "^13.0.0"
|
||||
}
|
||||
}
|
||||
120
auth/src/core/auth.core.js
Normal file
120
auth/src/core/auth.core.js
Normal 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
|
||||
}
|
||||
56
auth/src/core/session.core.js
Normal file
56
auth/src/core/session.core.js
Normal 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
49
auth/src/index.js
Normal 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
97
auth/src/routes/auth.js
Normal 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;
|
||||
0
auth/src/routes/sessions.js
Normal file
0
auth/src/routes/sessions.js
Normal file
2
auth/src/routes/users.js
Normal file
2
auth/src/routes/users.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const router = require('express').Router();
|
||||
|
||||
BIN
auth/src/static/icon.png
Normal file
BIN
auth/src/static/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
99
auth/src/static/style/login.css
Normal file
99
auth/src/static/style/login.css
Normal 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);
|
||||
}
|
||||
201
auth/src/static/style/style.css
Normal file
201
auth/src/static/style/style.css
Normal 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;
|
||||
}
|
||||
105
auth/src/storage/database.js
Normal file
105
auth/src/storage/database.js
Normal 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
36
auth/src/storage/redis.js
Normal 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 };
|
||||
39
auth/src/templates/loginpage.html
Normal file
39
auth/src/templates/loginpage.html
Normal 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>
|
||||
1
auth/src/templates/sessions.html
Normal file
1
auth/src/templates/sessions.html
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
auth/src/templates/user.html
Normal file
1
auth/src/templates/user.html
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
70
auth/src/tools/jwt.js
Normal file
70
auth/src/tools/jwt.js
Normal 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 };
|
||||
54
auth/src/tools/security.js
Normal file
54
auth/src/tools/security.js
Normal 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
|
||||
};
|
||||
24
auth/src/tools/tracking.js
Normal file
24
auth/src/tools/tracking.js
Normal 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user