Add scouthub-home-be
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { healthRouter } from './routes/health.routes';
|
||||
import { gruppiRouter } from './routes/gruppi.routes';
|
||||
import { invitiRouter } from './routes/inviti.routes';
|
||||
import { membriRouter } from './routes/membri.routes';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
|
||||
export const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(cors());
|
||||
|
||||
app.use(healthRouter);
|
||||
app.use(gruppiRouter);
|
||||
app.use(invitiRouter);
|
||||
app.use(membriRouter);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ message: 'not found' });
|
||||
});
|
||||
|
||||
app.use(errorHandler);
|
||||
@@ -0,0 +1,23 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Variabile d'ambiente mancante: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const env = {
|
||||
port: Number(process.env.PORT) || 8082,
|
||||
databaseUrl: requireEnv('DATABASE_URL'),
|
||||
frontendBaseUrl: requireEnv('FRONTEND_BASE_URL'),
|
||||
keycloak: {
|
||||
baseUrl: requireEnv('KEYCLOAK_BASE_URL'),
|
||||
realm: requireEnv('KEYCLOAK_REALM'),
|
||||
orgServiceClientId: requireEnv('KEYCLOAK_ORG_SERVICE_CLIENT_ID'),
|
||||
orgServiceClientSecret: requireEnv('KEYCLOAK_ORG_SERVICE_CLIENT_SECRET'),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { createGruppo } from '../services/gruppi.service';
|
||||
import { HttpError } from '../errors';
|
||||
|
||||
interface PostGruppoBody {
|
||||
nome?: unknown;
|
||||
regione?: unknown;
|
||||
ruoliDefault?: unknown;
|
||||
}
|
||||
|
||||
function parseBody(body: PostGruppoBody): { nome: string; regione?: string; ruoliDefault?: string[] } {
|
||||
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
|
||||
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
|
||||
}
|
||||
|
||||
if (body.regione !== undefined && typeof body.regione !== 'string') {
|
||||
throw new HttpError(400, "Il campo 'regione', se presente, deve essere una stringa");
|
||||
}
|
||||
|
||||
if (body.ruoliDefault !== undefined) {
|
||||
const isArrayOfStrings = Array.isArray(body.ruoliDefault) && body.ruoliDefault.every((r) => typeof r === 'string');
|
||||
if (!isArrayOfStrings) {
|
||||
throw new HttpError(400, "Il campo 'ruoliDefault', se presente, deve essere un array di stringhe");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nome: body.nome,
|
||||
regione: body.regione as string | undefined,
|
||||
ruoliDefault: body.ruoliDefault as string[] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function postGruppo(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const input = parseBody(req.body ?? {});
|
||||
const result = await createGruppo({ ...input, userId: req.auth!.userId });
|
||||
res.status(201).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { healthService } from '../services/health.service';
|
||||
|
||||
export async function getHealth(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
await healthService.checkDatabase();
|
||||
res.json({ status: 'ok', database: 'up' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { creaInvito, getInvitoPubblico, accettaInvito } from '../services/inviti.service';
|
||||
import { HttpError } from '../errors';
|
||||
|
||||
interface PostInvitoBody {
|
||||
email?: unknown;
|
||||
ruolo?: unknown;
|
||||
}
|
||||
|
||||
function parseCreaInvitoBody(body: PostInvitoBody): { email: string; ruolo: string } {
|
||||
if (typeof body.email !== 'string' || body.email.trim().length === 0) {
|
||||
throw new HttpError(400, "Il campo 'email' è obbligatorio ed è una stringa non vuota");
|
||||
}
|
||||
if (typeof body.ruolo !== 'string' || body.ruolo.trim().length === 0) {
|
||||
throw new HttpError(400, "Il campo 'ruolo' è obbligatorio ed è una stringa non vuota");
|
||||
}
|
||||
return { email: body.email, ruolo: body.ruolo };
|
||||
}
|
||||
|
||||
export async function postInvito(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { orgId } = req.params;
|
||||
|
||||
// Un capo gruppo può invitare solo all'interno della propria organization.
|
||||
if (req.auth?.organizationId !== orgId) {
|
||||
throw new HttpError(403, "Non puoi invitare persone in un'organizzazione diversa dalla tua");
|
||||
}
|
||||
|
||||
const { email, ruolo } = parseCreaInvitoBody(req.body ?? {});
|
||||
const result = await creaInvito({ orgId, email, ruolo });
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInvito(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
const invito = await getInvitoPubblico(token);
|
||||
|
||||
if (!invito) {
|
||||
throw new HttpError(404, 'Invito non trovato');
|
||||
}
|
||||
|
||||
res.json(invito);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function postAccettaInvito(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
const result = await accettaInvito(token, {
|
||||
userId: req.auth!.userId,
|
||||
email: req.auth!.email,
|
||||
});
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { listaMembri, cambiaRuoloMembro, rimuoviMembro } from '../services/membri.service';
|
||||
import { HttpError } from '../errors';
|
||||
|
||||
function checkOrgAccess(req: Request): string {
|
||||
const { orgId } = req.params;
|
||||
if (req.auth?.organizationId !== orgId) {
|
||||
throw new HttpError(403, "Non puoi gestire membri di un'organizzazione diversa dalla tua");
|
||||
}
|
||||
return orgId;
|
||||
}
|
||||
|
||||
export async function getMembri(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const orgId = checkOrgAccess(req);
|
||||
const membri = await listaMembri(orgId);
|
||||
res.json(membri);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function putRuoloMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const orgId = checkOrgAccess(req);
|
||||
const { userId } = req.params;
|
||||
const { ruolo } = req.body ?? {};
|
||||
|
||||
if (typeof ruolo !== 'string' || ruolo.trim().length === 0) {
|
||||
throw new HttpError(400, "Il campo 'ruolo' è obbligatorio ed è una stringa non vuota");
|
||||
}
|
||||
|
||||
await cambiaRuoloMembro(orgId, userId, ruolo);
|
||||
res.status(200).json({ userId, ruolo });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const orgId = checkOrgAccess(req);
|
||||
const { userId } = req.params;
|
||||
|
||||
await rimuoviMembro(orgId, userId);
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma = global.__prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
global.__prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class HttpError extends Error {
|
||||
statusCode: number;
|
||||
|
||||
constructor(statusCode: number, message: string) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export async function assignUserToGroup(userId: string, groupId: string): Promise<void> {
|
||||
// TODO: PUT {adminBaseUrl}/users/{userId}/groups/{groupId}
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
export async function removeUserFromGroup(userId: string, groupId: string): Promise<void> {
|
||||
// TODO: DELETE {adminBaseUrl}/users/{userId}/groups/{groupId}
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
export interface UserGroup {
|
||||
groupId: string;
|
||||
nome: string;
|
||||
}
|
||||
|
||||
export async function getUserGroupsInOrganization(orgId: string, userId: string): Promise<UserGroup[]> {
|
||||
// TODO: GET {adminBaseUrl}/users/{userId}/groups, filtrati ai gruppi che
|
||||
// appartengono all'albero dei gruppi dell'organizzazione orgId (o endpoint
|
||||
// dedicato, da verificare in base alla versione di Keycloak).
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import axios from 'axios';
|
||||
import { env } from '../config/env';
|
||||
import { getAccessToken } from './tokenManager';
|
||||
|
||||
export const keycloakAdminHttp = axios.create({
|
||||
baseURL: `${env.keycloak.baseUrl}/admin/realms/${env.keycloak.realm}`,
|
||||
});
|
||||
|
||||
keycloakAdminHttp.interceptors.request.use(async (config) => {
|
||||
const token = await getAccessToken();
|
||||
config.headers.set('Authorization', `Bearer ${token}`);
|
||||
return config;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export { getAccessToken } from './tokenManager';
|
||||
export { keycloakAdminHttp } from './httpClient';
|
||||
export {
|
||||
createOrganization,
|
||||
createOrganizationGroup,
|
||||
addMemberToOrganization,
|
||||
listOrganizationMembers,
|
||||
removeMemberFromOrganization,
|
||||
} from './organizations';
|
||||
export { assignUserToGroup, removeUserFromGroup, getUserGroupsInOrganization } from './groups';
|
||||
export { assignRealmRoleToUser, removeRealmRoleFromUser, getUserRealmRoles } from './roles';
|
||||
export { findUserByEmail, createUser } from './users';
|
||||
@@ -0,0 +1,101 @@
|
||||
import axios from 'axios';
|
||||
import { keycloakAdminHttp } from './httpClient';
|
||||
|
||||
// Le risposte 201 di Keycloak restituiscono l'id della risorsa creata solo
|
||||
// nell'header Location (es. ".../organizations/3f2b...").
|
||||
function extractIdFromLocation(location: string | undefined): string {
|
||||
if (!location) {
|
||||
throw new Error("Risposta Keycloak priva dell'header Location");
|
||||
}
|
||||
const id = location.split('/').pop();
|
||||
if (!id) {
|
||||
throw new Error(`Impossibile estrarre l'id dall'header Location: "${location}"`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// L'alias dell'organizzazione è vincolato da Keycloak a un formato URL-safe:
|
||||
// verificato contro un'istanza reale che uno spazio nel nome (comunissimo nei
|
||||
// nomi di gruppo scout, es. "Milano 1") viene rifiutato con 400 "Empty Space
|
||||
// not allowed", a differenza del campo "name" che accetta testo libero.
|
||||
function toAlias(nome: string): string {
|
||||
return nome
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export async function createOrganization(nome: string): Promise<{ orgId: string }> {
|
||||
const response = await keycloakAdminHttp.post('/organizations', {
|
||||
name: nome,
|
||||
alias: toAlias(nome),
|
||||
enabled: true,
|
||||
domains: [],
|
||||
});
|
||||
return { orgId: extractIdFromLocation(response.headers.location) };
|
||||
}
|
||||
|
||||
// Prefisso per il gruppo realm padre di ogni organizzazione. Keycloak riserva
|
||||
// internamente un gruppo top-level il cui nome coincide con l'id
|
||||
// dell'organizzazione (non elencato da GET /groups, ma verificato contro
|
||||
// un'istanza reale: un POST /groups con quel nome esatto risponde comunque
|
||||
// 409 "already exists"). Va quindi evitato un nome che collida con quello.
|
||||
const GRUPPO_PADRE_PREFIX = 'org-';
|
||||
|
||||
export async function createOrganizationGroup(orgId: string, nomeGruppo: string): Promise<{ groupId: string }> {
|
||||
// Le Organizations di Keycloak 26 non hanno un concetto nativo di "gruppo
|
||||
// ruolo": usiamo un gruppo realm nidificato sotto un gruppo padre dedicato
|
||||
// all'organizzazione, per isolare i gruppi ruolo di organizzazioni diverse
|
||||
// anche se condividono lo stesso nome di ruolo (es. "Capi"). Il gruppo
|
||||
// padre viene creato al primo ruolo e riusato per gli altri: la 409 sul
|
||||
// secondo tentativo è quindi attesa, non un errore.
|
||||
const nomeGruppoPadre = `${GRUPPO_PADRE_PREFIX}${orgId}`;
|
||||
let parentGroupId: string;
|
||||
try {
|
||||
const parentResponse = await keycloakAdminHttp.post('/groups', { name: nomeGruppoPadre });
|
||||
parentGroupId = extractIdFromLocation(parentResponse.headers.location);
|
||||
} catch (err) {
|
||||
if (!axios.isAxiosError(err) || err.response?.status !== 409) {
|
||||
throw err;
|
||||
}
|
||||
const searchResponse = await keycloakAdminHttp.get<Array<{ id: string; name: string }>>('/groups', {
|
||||
params: { search: nomeGruppoPadre, exact: true },
|
||||
});
|
||||
const existing = searchResponse.data.find((g) => g.name === nomeGruppoPadre);
|
||||
if (!existing) {
|
||||
throw new Error(`Gruppo padre "${nomeGruppoPadre}" segnalato come duplicato ma non trovato nella ricerca`);
|
||||
}
|
||||
parentGroupId = existing.id;
|
||||
}
|
||||
|
||||
const childResponse = await keycloakAdminHttp.post(`/groups/${parentGroupId}/children`, { name: nomeGruppo });
|
||||
return { groupId: extractIdFromLocation(childResponse.headers.location) };
|
||||
}
|
||||
|
||||
export async function addMemberToOrganization(orgId: string, userId: string): Promise<void> {
|
||||
// Verificato contro un'istanza Keycloak 26.7 reale: l'endpoint richiede
|
||||
// Content-Type application/json con il solo id utente come stringa JSON nel
|
||||
// body (non un oggetto, e non text/plain: entrambi rispondono 415).
|
||||
await keycloakAdminHttp.post(`/organizations/${orgId}/members`, JSON.stringify(userId), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export interface OrganizationMember {
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export async function listOrganizationMembers(orgId: string): Promise<OrganizationMember[]> {
|
||||
const response = await keycloakAdminHttp.get<Array<{ id: string; email: string }>>(
|
||||
`/organizations/${orgId}/members`,
|
||||
);
|
||||
return response.data.map((member) => ({ userId: member.id, email: member.email }));
|
||||
}
|
||||
|
||||
export async function removeMemberFromOrganization(orgId: string, userId: string): Promise<void> {
|
||||
await keycloakAdminHttp.delete(`/organizations/${orgId}/members/${userId}`);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export async function assignRealmRoleToUser(userId: string, ruolo: string): Promise<void> {
|
||||
// TODO: POST {adminBaseUrl}/users/{userId}/role-mappings/realm con il
|
||||
// rappresentante del ruolo (richiede prima GET {adminBaseUrl}/roles/{ruolo}
|
||||
// per ottenerne id e name).
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
export async function removeRealmRoleFromUser(userId: string, ruolo: string): Promise<void> {
|
||||
// TODO: DELETE {adminBaseUrl}/users/{userId}/role-mappings/realm con il
|
||||
// rappresentante del ruolo.
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
export async function getUserRealmRoles(userId: string): Promise<string[]> {
|
||||
// TODO: GET {adminBaseUrl}/users/{userId}/role-mappings/realm. Il contratto
|
||||
// di questa funzione è di restituire solo i ruoli scout "custom" (es. le
|
||||
// voci di ruoliDefault create in POST /gruppi), escludendo i ruoli di
|
||||
// default di Keycloak (offline_access, uma_authorization, ecc.).
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import axios from 'axios';
|
||||
import { env } from '../config/env';
|
||||
import { TokenResponse } from './types';
|
||||
|
||||
// Margine di sicurezza sotto il quale consideriamo il token "in scadenza"
|
||||
// e ne richiediamo uno nuovo, invece di rischiare di usarne uno già scaduto
|
||||
// a causa della latenza della chiamata Admin API successiva.
|
||||
const EXPIRY_SAFETY_MARGIN_MS = 10_000;
|
||||
|
||||
interface CachedToken {
|
||||
accessToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
let cachedToken: CachedToken | null = null;
|
||||
let pendingRefresh: Promise<string> | null = null;
|
||||
|
||||
function tokenEndpoint(): string {
|
||||
return `${env.keycloak.baseUrl}/realms/${env.keycloak.realm}/protocol/openid-connect/token`;
|
||||
}
|
||||
|
||||
async function requestNewToken(): Promise<string> {
|
||||
const response = await axios.post<TokenResponse>(
|
||||
tokenEndpoint(),
|
||||
new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: env.keycloak.orgServiceClientId,
|
||||
client_secret: env.keycloak.orgServiceClientSecret,
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
||||
);
|
||||
|
||||
const { access_token, expires_in } = response.data;
|
||||
cachedToken = {
|
||||
accessToken: access_token,
|
||||
expiresAt: Date.now() + expires_in * 1000,
|
||||
};
|
||||
return cachedToken.accessToken;
|
||||
}
|
||||
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
if (cachedToken && cachedToken.expiresAt - EXPIRY_SAFETY_MARGIN_MS > Date.now()) {
|
||||
return cachedToken.accessToken;
|
||||
}
|
||||
|
||||
// Se un rinnovo è già in corso, tutte le chiamate concorrenti aspettano
|
||||
// lo stesso risultato invece di richiedere ciascuna un nuovo token.
|
||||
if (!pendingRefresh) {
|
||||
pendingRefresh = requestNewToken().finally(() => {
|
||||
pendingRefresh = null;
|
||||
});
|
||||
}
|
||||
return pendingRefresh;
|
||||
}
|
||||
|
||||
// Esportata solo per i test: azzera lo stato in-memory del modulo tra un test e l'altro.
|
||||
export function resetTokenCache(): void {
|
||||
cachedToken = null;
|
||||
pendingRefresh = null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface KeycloakUserSummary {
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { KeycloakUserSummary } from './types';
|
||||
|
||||
export async function findUserByEmail(email: string): Promise<KeycloakUserSummary | null> {
|
||||
// TODO: GET {adminBaseUrl}/users?email={email}&exact=true, restituire il
|
||||
// primo risultato mappato a { id } oppure null se l'array è vuoto.
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
export async function createUser(email: string, datiProfilo: object): Promise<{ userId: string }> {
|
||||
// TODO: POST {adminBaseUrl}/users con { email, ...datiProfilo, enabled: true },
|
||||
// leggere l'id dall'header Location della risposta.
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface AuthContext {
|
||||
userId: string;
|
||||
email: string | null;
|
||||
organizationId: string | null;
|
||||
roles: string[];
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||
import jwksClient from 'jwks-rsa';
|
||||
import { env } from '../config/env';
|
||||
import { AuthContext } from './auth.types';
|
||||
|
||||
const client = jwksClient({
|
||||
jwksUri: `${env.keycloak.baseUrl}/realms/${env.keycloak.realm}/protocol/openid-connect/certs`,
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
});
|
||||
|
||||
interface KeycloakTokenPayload extends JwtPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
realm_access?: { roles?: string[] };
|
||||
// Claim iniettato dalla feature "organizations" di Keycloak: mappa alias
|
||||
// organizzazione -> { id, roles dell'utente in quella organizzazione }.
|
||||
// Un token porta al più un'organizzazione attiva per volta.
|
||||
organization?: Record<string, { id: string; roles?: string[] }>;
|
||||
}
|
||||
|
||||
function extractBearerToken(req: Request): string | null {
|
||||
const header = req.headers.authorization;
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
const [scheme, token] = header.split(' ');
|
||||
if (scheme !== 'Bearer' || !token) {
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function buildAuthContext(payload: KeycloakTokenPayload): AuthContext {
|
||||
const realmRoles = payload.realm_access?.roles ?? [];
|
||||
const [organization] = payload.organization ? Object.values(payload.organization) : [];
|
||||
const orgRoles = organization?.roles ?? [];
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
email: payload.email ?? null,
|
||||
organizationId: organization?.id ?? null,
|
||||
roles: Array.from(new Set([...realmRoles, ...orgRoles])),
|
||||
};
|
||||
}
|
||||
|
||||
export async function authenticate(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
const token = extractBearerToken(req);
|
||||
if (!token) {
|
||||
res.status(401).json({ message: 'Token mancante' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
if (!decoded || !decoded.header.kid) {
|
||||
throw new Error("Header del token privo di 'kid'");
|
||||
}
|
||||
|
||||
const signingKey = await client.getSigningKey(decoded.header.kid);
|
||||
const payload = jwt.verify(token, signingKey.getPublicKey(), { algorithms: ['RS256'] });
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
throw new Error('Payload del token non valido');
|
||||
}
|
||||
|
||||
req.auth = buildAuthContext(payload as KeycloakTokenPayload);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ message: 'Token non valido' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction): void {
|
||||
const hasStatusCode =
|
||||
typeof err === 'object' && err !== null && 'statusCode' in err && typeof (err as { statusCode?: unknown }).statusCode === 'number';
|
||||
const statusCode = hasStatusCode ? (err as { statusCode: number }).statusCode : 500;
|
||||
const message = err instanceof Error && err.message ? err.message : 'Errore interno del server';
|
||||
|
||||
res.status(statusCode).json({ message });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function requireRole(...ruoliAmmessi: string[]) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const roles = req.auth?.roles ?? [];
|
||||
const autorizzato = ruoliAmmessi.some((ruolo) => roles.includes(ruolo));
|
||||
|
||||
if (!autorizzato) {
|
||||
res.status(403).json({ message: 'Ruolo non autorizzato' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/authenticate';
|
||||
import { requireRole } from '../middleware/requireRole';
|
||||
import { postGruppo } from '../controllers/gruppi.controller';
|
||||
|
||||
export const gruppiRouter = Router();
|
||||
|
||||
// TODO: "admin-centrale" è temporaneo, in attesa di definire i ruoli reali
|
||||
// abilitati alla creazione di un nuovo gruppo scout.
|
||||
gruppiRouter.post('/gruppi', authenticate, requireRole('admin-centrale'), postGruppo);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { getHealth } from '../controllers/health.controller';
|
||||
|
||||
export const healthRouter = Router();
|
||||
|
||||
healthRouter.get('/health', getHealth);
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/authenticate';
|
||||
import { requireRole } from '../middleware/requireRole';
|
||||
import { postInvito, getInvito, postAccettaInvito } from '../controllers/inviti.controller';
|
||||
|
||||
export const invitiRouter = Router();
|
||||
|
||||
invitiRouter.post('/gruppi/:orgId/inviti', authenticate, requireRole('capo-gruppo'), postInvito);
|
||||
|
||||
invitiRouter.get('/inviti/:token', getInvito);
|
||||
|
||||
invitiRouter.post('/inviti/:token/accetta', authenticate, postAccettaInvito);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/authenticate';
|
||||
import { requireRole } from '../middleware/requireRole';
|
||||
import { getMembri, putRuoloMembro, deleteMembro } from '../controllers/membri.controller';
|
||||
|
||||
export const membriRouter = Router();
|
||||
|
||||
membriRouter.get('/gruppi/:orgId/membri', authenticate, requireRole('capo-gruppo'), getMembri);
|
||||
membriRouter.put('/gruppi/:orgId/membri/:userId/ruolo', authenticate, requireRole('capo-gruppo'), putRuoloMembro);
|
||||
membriRouter.delete('/gruppi/:orgId/membri/:userId', authenticate, requireRole('capo-gruppo'), deleteMembro);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { app } from './app';
|
||||
import { env } from './config/env';
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`Server avviato su porta ${env.port}`);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import axios from 'axios';
|
||||
import { prisma } from '../db/prisma';
|
||||
import { createOrganization, createOrganizationGroup, addMemberToOrganization } from '../keycloak-admin';
|
||||
import { HttpError } from '../errors';
|
||||
|
||||
export const RUOLI_DEFAULT = ['Capi', 'Aiuto capi', 'Censiti'];
|
||||
|
||||
export interface CreateGruppoInput {
|
||||
nome: string;
|
||||
userId: string;
|
||||
regione?: string;
|
||||
ruoliDefault?: string[];
|
||||
}
|
||||
|
||||
export interface CreateGruppoResult {
|
||||
orgId: string;
|
||||
gruppiCreati: string[];
|
||||
}
|
||||
|
||||
function isKeycloakConflict(err: unknown): boolean {
|
||||
return axios.isAxiosError(err) && err.response?.status === 409;
|
||||
}
|
||||
|
||||
export async function createGruppo(input: CreateGruppoInput): Promise<CreateGruppoResult> {
|
||||
const ruoliDefault = input.ruoliDefault && input.ruoliDefault.length > 0 ? input.ruoliDefault : RUOLI_DEFAULT;
|
||||
|
||||
// Step 1: organizzazione Keycloak.
|
||||
let orgId: string;
|
||||
try {
|
||||
({ orgId } = await createOrganization(input.nome));
|
||||
} catch (err) {
|
||||
if (isKeycloakConflict(err)) {
|
||||
throw new HttpError(409, `Esiste già un gruppo scout con nome "${input.nome}"`);
|
||||
}
|
||||
console.error(
|
||||
`[gruppi] STEP 1 (createOrganization) fallito per nome="${input.nome}". Nessuna risorsa creata: ` +
|
||||
'il retry può ripartire dall\'inizio.',
|
||||
err,
|
||||
);
|
||||
throw new HttpError(502, "Impossibile creare l'organizzazione su Keycloak");
|
||||
}
|
||||
|
||||
// Step 2: un gruppo Keycloak per ciascun ruolo di default.
|
||||
const gruppiCreati: string[] = [];
|
||||
for (const ruolo of ruoliDefault) {
|
||||
try {
|
||||
await createOrganizationGroup(orgId, ruolo);
|
||||
gruppiCreati.push(ruolo);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[gruppi] STEP 2 (createOrganizationGroup) fallito per orgId="${orgId}", ruolo="${ruolo}". ` +
|
||||
`Gruppi già creati con successo su Keycloak: [${gruppiCreati.join(', ')}]. ` +
|
||||
`L'organizzazione orgId="${orgId}" esiste già su Keycloak: per il retry manuale non richiamare ` +
|
||||
'createOrganization, ma solo i gruppi ruolo mancanti seguiti dal salvataggio locale.',
|
||||
err,
|
||||
);
|
||||
throw new HttpError(502, 'Impossibile creare uno dei gruppi ruolo su Keycloak');
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: il creatore diventa membro dell'organizzazione appena creata.
|
||||
// Senza questo passaggio il claim "organization" non comparirebbe mai nel
|
||||
// suo token, e la auth guard del FE lo rimanderebbe sempre su "crea gruppo"
|
||||
// anche a creazione riuscita.
|
||||
try {
|
||||
await addMemberToOrganization(orgId, input.userId);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[gruppi] STEP 3 (addMemberToOrganization) fallito per orgId="${orgId}", userId="${input.userId}". ` +
|
||||
`Organizzazione e gruppi [${gruppiCreati.join(', ')}] già creati su Keycloak: per il retry manuale ` +
|
||||
`non richiamare le API di creazione, ma solo l'aggiunta del membro con orgId="${orgId}".`,
|
||||
err,
|
||||
);
|
||||
throw new HttpError(502, "Impossibile aggiungere l'utente all'organizzazione su Keycloak");
|
||||
}
|
||||
|
||||
// Step 4: riga locale in gruppo_scout.
|
||||
try {
|
||||
await prisma.gruppoScout.create({
|
||||
data: { orgId, nome: input.nome, regione: input.regione },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[gruppi] STEP 4 (salvataggio locale gruppo_scout) fallito per orgId="${orgId}", nome="${input.nome}". ` +
|
||||
`Organizzazione, gruppi [${gruppiCreati.join(', ')}] e membership già creati su Keycloak: per il ` +
|
||||
`retry manuale non richiamare le API Keycloak, ma solo il salvataggio locale con orgId="${orgId}".`,
|
||||
err,
|
||||
);
|
||||
throw new HttpError(500, 'Impossibile salvare il gruppo scout localmente');
|
||||
}
|
||||
|
||||
return { orgId, gruppiCreati };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { prisma } from '../db/prisma';
|
||||
|
||||
export class HealthService {
|
||||
async checkDatabase(): Promise<boolean> {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const healthService = new HealthService();
|
||||
@@ -0,0 +1,123 @@
|
||||
import crypto from 'crypto';
|
||||
import { prisma } from '../db/prisma';
|
||||
import { addMemberToOrganization, assignUserToGroup, assignRealmRoleToUser } from '../keycloak-admin';
|
||||
import { HttpError } from '../errors';
|
||||
import { env } from '../config/env';
|
||||
|
||||
export const STATO_INVITO = {
|
||||
PENDING: 'pending',
|
||||
ACCETTATO: 'accettato',
|
||||
} as const;
|
||||
|
||||
const DURATA_INVITO_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface CreaInvitoInput {
|
||||
orgId: string;
|
||||
email: string;
|
||||
ruolo: string;
|
||||
}
|
||||
|
||||
export interface CreaInvitoResult {
|
||||
invitoId: string;
|
||||
scadenza: Date;
|
||||
}
|
||||
|
||||
export async function creaInvito(input: CreaInvitoInput): Promise<CreaInvitoResult> {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const scadenza = new Date(Date.now() + DURATA_INVITO_MS);
|
||||
|
||||
const invito = await prisma.invito.create({
|
||||
data: {
|
||||
token,
|
||||
email: input.email,
|
||||
orgId: input.orgId,
|
||||
ruolo: input.ruolo,
|
||||
scadenza,
|
||||
stato: STATO_INVITO.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: sostituire con un vero invio email quando sarà disponibile un servizio dedicato.
|
||||
console.log(`[inviti] invito per ${input.email} (ruolo "${input.ruolo}"): ${env.frontendBaseUrl}/inviti/${token}`);
|
||||
|
||||
return { invitoId: invito.id, scadenza };
|
||||
}
|
||||
|
||||
export interface InvitoPubblico {
|
||||
email: string;
|
||||
nomeGruppo: string;
|
||||
ruolo: string;
|
||||
valido: boolean;
|
||||
}
|
||||
|
||||
export async function getInvitoPubblico(token: string): Promise<InvitoPubblico | null> {
|
||||
const invito = await prisma.invito.findUnique({
|
||||
where: { token },
|
||||
include: { gruppoScout: true },
|
||||
});
|
||||
|
||||
if (!invito) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valido = invito.stato === STATO_INVITO.PENDING && invito.scadenza.getTime() > Date.now();
|
||||
|
||||
return {
|
||||
email: invito.email,
|
||||
nomeGruppo: invito.gruppoScout.nome,
|
||||
ruolo: invito.ruolo,
|
||||
valido,
|
||||
};
|
||||
}
|
||||
|
||||
export interface AccettaInvitoResult {
|
||||
organizationId: string;
|
||||
ruolo: string;
|
||||
}
|
||||
|
||||
export async function accettaInvito(
|
||||
token: string,
|
||||
utente: { userId: string; email: string | null },
|
||||
): Promise<AccettaInvitoResult> {
|
||||
const invito = await prisma.invito.findUnique({ where: { token } });
|
||||
|
||||
if (!invito) {
|
||||
throw new HttpError(404, 'Invito non trovato');
|
||||
}
|
||||
|
||||
if (invito.stato !== STATO_INVITO.PENDING) {
|
||||
throw new HttpError(409, "L'invito è già stato accettato");
|
||||
}
|
||||
|
||||
if (invito.scadenza.getTime() <= Date.now()) {
|
||||
throw new HttpError(410, 'Invito scaduto');
|
||||
}
|
||||
|
||||
if (!utente.email || utente.email.toLowerCase() !== invito.email.toLowerCase()) {
|
||||
throw new HttpError(403, "L'utente autenticato non corrisponde al destinatario dell'invito");
|
||||
}
|
||||
|
||||
try {
|
||||
await addMemberToOrganization(invito.orgId, utente.userId);
|
||||
// TODO: "ruolo" è usato qui anche come identificativo del gruppo Keycloak,
|
||||
// in attesa che createOrganizationGroup persista una mappa ruolo -> groupId
|
||||
// reale da risolvere in questo punto.
|
||||
await assignUserToGroup(utente.userId, invito.ruolo);
|
||||
await assignRealmRoleToUser(utente.userId, invito.ruolo);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[inviti] fallita l'assegnazione Keycloak per l'accettazione dell'invito token="${token}", ` +
|
||||
`userId="${utente.userId}", orgId="${invito.orgId}". Nessun cambio di stato locale: ` +
|
||||
"l'invito resta 'pending' e l'utente può ritentare l'accettazione.",
|
||||
err,
|
||||
);
|
||||
throw new HttpError(502, "Impossibile completare l'assegnazione su Keycloak");
|
||||
}
|
||||
|
||||
await prisma.invito.update({
|
||||
where: { token },
|
||||
data: { stato: STATO_INVITO.ACCETTATO },
|
||||
});
|
||||
|
||||
return { organizationId: invito.orgId, ruolo: invito.ruolo };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
listOrganizationMembers,
|
||||
removeMemberFromOrganization,
|
||||
assignUserToGroup,
|
||||
removeUserFromGroup,
|
||||
getUserGroupsInOrganization,
|
||||
assignRealmRoleToUser,
|
||||
removeRealmRoleFromUser,
|
||||
getUserRealmRoles,
|
||||
} from '../keycloak-admin';
|
||||
import { HttpError } from '../errors';
|
||||
|
||||
export interface MembroOrganization {
|
||||
userId: string;
|
||||
email: string;
|
||||
ruolo: string | null;
|
||||
gruppoInterno: string | null;
|
||||
}
|
||||
|
||||
export async function listaMembri(orgId: string): Promise<MembroOrganization[]> {
|
||||
const membri = await listOrganizationMembers(orgId);
|
||||
|
||||
return Promise.all(
|
||||
membri.map(async (membro) => {
|
||||
const [gruppi, ruoli] = await Promise.all([
|
||||
getUserGroupsInOrganization(orgId, membro.userId),
|
||||
getUserRealmRoles(membro.userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
userId: membro.userId,
|
||||
email: membro.email,
|
||||
gruppoInterno: gruppi[0]?.nome ?? null,
|
||||
ruolo: ruoli[0] ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cambiaRuoloMembro(orgId: string, userId: string, nuovoRuolo: string): Promise<void> {
|
||||
try {
|
||||
const gruppiAttuali = await getUserGroupsInOrganization(orgId, userId);
|
||||
for (const gruppo of gruppiAttuali) {
|
||||
await removeUserFromGroup(userId, gruppo.groupId);
|
||||
}
|
||||
// TODO: come nel flusso di invito, "ruolo" è usato anche come
|
||||
// identificativo del gruppo Keycloak, in attesa di una mappa
|
||||
// ruolo -> groupId reale (vedi src/services/inviti.service.ts).
|
||||
await assignUserToGroup(userId, nuovoRuolo);
|
||||
|
||||
const ruoliAttuali = await getUserRealmRoles(userId);
|
||||
for (const ruolo of ruoliAttuali) {
|
||||
await removeRealmRoleFromUser(userId, ruolo);
|
||||
}
|
||||
await assignRealmRoleToUser(userId, nuovoRuolo);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[membri] cambio ruolo fallito per orgId="${orgId}", userId="${userId}", nuovoRuolo="${nuovoRuolo}". ` +
|
||||
"Lo stato su Keycloak potrebbe essere stato aggiornato solo parzialmente: verificare manualmente " +
|
||||
'gruppo/ruoli correnti dell\'utente prima di ritentare.',
|
||||
err,
|
||||
);
|
||||
throw new HttpError(502, "Impossibile completare l'aggiornamento del ruolo su Keycloak");
|
||||
}
|
||||
}
|
||||
|
||||
export async function rimuoviMembro(orgId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
await removeMemberFromOrganization(orgId, userId);
|
||||
} catch (err) {
|
||||
console.error(`[membri] rimozione membro fallita per orgId="${orgId}", userId="${userId}".`, err);
|
||||
throw new HttpError(502, "Impossibile rimuovere il membro dall'organizzazione su Keycloak");
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { AuthContext } from '../middleware/auth.types';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
auth?: AuthContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user