Add scouthub-eventi-be
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { HttpError } from '../errors';
|
||||
import { AuthContext } from './auth.types';
|
||||
|
||||
// Lancia 403 se l'utente non appartiene alla branca indicata. Chi ha ruolo
|
||||
// capo-gruppo (realm o sull'organizzazione attiva del token, gia' confluiti in
|
||||
// user.roles da verifyToken) salta il controllo: ha accesso in scrittura a
|
||||
// tutte le branche del proprio gruppo.
|
||||
export function assertBrancaAccess(user: AuthContext, brancaId: string): void {
|
||||
if (user.roles.includes('capo-gruppo')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAccess = user.branche.some((branca) => branca.toLowerCase() === brancaId.toLowerCase());
|
||||
if (!hasAccess) {
|
||||
throw new HttpError(403, `Accesso alla branca '${brancaId}' non consentito`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface AuthContext {
|
||||
userId: string;
|
||||
email: string | null;
|
||||
orgId: string | null;
|
||||
roles: string[];
|
||||
// Branche dell'utente, ricavate dal claim "groups" del token (path normalizzati,
|
||||
// senza lo slash iniziale: es. "Lupetti", "Capi"). Vedi assertBrancaAccess.
|
||||
branche: string[];
|
||||
}
|
||||
|
||||
// Contesto delle chiamate machine-to-machine (client credentials), popolato da
|
||||
// verifyServiceToken. Distinto da AuthContext: non rappresenta un utente reale.
|
||||
export interface ServiceAuthContext {
|
||||
clientId: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
export 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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import jwksClient from 'jwks-rsa';
|
||||
import { env } from '../config/env';
|
||||
|
||||
// Client JWKS condiviso tra verifyToken e verifyServiceToken: stesso realm,
|
||||
// stessa chiave pubblica di firma.
|
||||
export const jwksClientInstance = jwksClient({
|
||||
jwksUri: `${env.keycloak.baseUrl}/realms/${env.keycloak.realm}/protocol/openid-connect/certs`,
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// Da usare dopo verifyToken su tutte le route utente (mai insieme a verifyServiceToken):
|
||||
// richiede che il token porti un'organizzazione attiva. L'org_id da usare per filtrare
|
||||
// le query e' sempre req.auth.orgId, mai un org_id letto da params/query/body.
|
||||
export function requireOrgId(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.auth?.orgId) {
|
||||
res.status(403).json({ message: 'Nessuna organizzazione attiva sul token' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||
import { jwksClientInstance } from './jwks-client';
|
||||
import { extractBearerToken } from './extract-bearer-token';
|
||||
import { env } from '../config/env';
|
||||
import { ServiceAuthContext } from './auth.types';
|
||||
|
||||
interface ServiceTokenPayload extends JwtPayload {
|
||||
// "azp" (authorized party) e' il client_id del client che ha ottenuto il token
|
||||
// via client credentials grant: e' il modo standard per riconoscere quale
|
||||
// servizio sta chiamando in una chiamata machine-to-machine.
|
||||
azp?: string;
|
||||
}
|
||||
|
||||
// Middleware da usare al posto di (non insieme a) verifyToken sulle route
|
||||
// machine-to-machine, ad es. POST /eventi/:id/risorse chiamata da altri backend
|
||||
// (scouthub-attivita-be, scouthub-magazzino-be, ...) con le proprie credenziali
|
||||
// di servizio. Il client chiamante deve essere elencato in
|
||||
// KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS.
|
||||
export async function verifyServiceToken(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 jwksClientInstance.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');
|
||||
}
|
||||
|
||||
const clientId = (payload as ServiceTokenPayload).azp;
|
||||
if (!clientId || !env.keycloak.authorizedServiceClients.includes(clientId)) {
|
||||
res.status(403).json({ message: 'Client di servizio non autorizzato' });
|
||||
return;
|
||||
}
|
||||
|
||||
const service: ServiceAuthContext = { clientId };
|
||||
req.service = service;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ message: 'Token non valido' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt, { JwtPayload } from 'jsonwebtoken';
|
||||
import { jwksClientInstance } from './jwks-client';
|
||||
import { extractBearerToken } from './extract-bearer-token';
|
||||
import { AuthContext } from './auth.types';
|
||||
|
||||
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 piu' un'organizzazione attiva per volta (vedi anche
|
||||
// scouthub-magazzino-be/src/auth/verify-token.middleware.ts).
|
||||
organization?: Record<string, { id: string; roles?: string[] }>;
|
||||
// scouthub-attivita-be non espone ad oggi un claim branca dedicato nel token
|
||||
// (vedi src/middlewares/authenticate.ts di quel progetto): finche' non verra'
|
||||
// introdotto, la branca/i gruppi dell'utente sono ricavati dal claim standard
|
||||
// "groups" di Keycloak, con path del tipo "/Lupetti", "/Capi".
|
||||
groups?: string[];
|
||||
}
|
||||
|
||||
function normalizeGroupPath(path: string): string {
|
||||
return path.replace(/^\//, '');
|
||||
}
|
||||
|
||||
function buildAuthContext(payload: KeycloakTokenPayload): AuthContext {
|
||||
const realmRoles = payload.realm_access?.roles ?? [];
|
||||
const [organization] = payload.organization ? Object.values(payload.organization) : [];
|
||||
const orgRoles = organization?.roles ?? [];
|
||||
const groups = payload.groups ?? [];
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
email: payload.email ?? null,
|
||||
orgId: organization?.id ?? null,
|
||||
roles: Array.from(new Set([...realmRoles, ...orgRoles])),
|
||||
branche: groups.map(normalizeGroupPath),
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyToken(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 jwksClientInstance.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' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user