feat(sprint-17 backend 2026-04-25): Public KPI Sharing — token signé HMAC-SHA256 + endpoints admin/public
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m3s
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m3s
Transparency réglementaire (Sprint 16.D différé livré en S17). Permet aux autorités
externes (BCEAO, ARTCI, CENTIF, contrôleurs UEMOA) de consulter les KPI agrégés
d'une organisation sans login, via lien signé temporairement.
Architecture sécurité
- KpiShareTokenService : HMAC-SHA256 + base64url, format orgId.expiryMillis.signature
- Vérification time-constant via MessageDigest.isEqual (résistant timing attacks)
- Secret @ConfigProperty unionflow.kpi.share.secret (défaut TTL 7 jours)
- Pas de DB — token autosuffisant, révocable seulement par rotation du secret
KpiPublicService
- snapshotPublic(orgId) : extrait sous-ensemble safe de ComplianceSnapshot
- Audit chaque accès public (méta-traçabilité — qui/quand consulté quoi)
Endpoints
- GET /api/admin/kpi/share-link/{orgId}?ttlSeconds=...
@RolesAllowed ADMIN_ORGANISATION, PRESIDENT, COMPLIANCE_OFFICER, SUPER_ADMIN
Retourne {token, ttlSeconds, publicUrl, publicWebPath}
- GET /api/public/kpi?token=...
@PermitAll (whitelist /api/public/* dans application.properties)
Retourne KpiPublicSnapshot ou 401/404 si token invalide/expiré ou org introuvable
Bump dépendance api 1.0.9 → 1.0.10 (DTO KpiPublicSnapshot ajouté)
Tests KpiShareTokenService (9 tests)
- Round-trip orgId, ttl 0/<=0 défaut, orgId null exception
- Token expiré (signature fake), tampering signature, tampering orgId
- Malformé (< 3 parts, > 4 parts)
- Null/blank
- Encode/decode round-trip avec caractères spéciaux
ACTION USER : mvn install api 1.0.10 puis tester impl/web.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
import dev.lions.unionflow.server.security.AuditTrailService;
|
||||
import dev.lions.unionflow.server.service.compliance.KpiShareTokenService;
|
||||
import io.quarkus.security.Authenticated;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Génération de liens KPI signés (Sprint 17) — admin uniquement.
|
||||
*
|
||||
* <p>Crée un token signé HMAC-SHA256 valide pendant {@code ttlSeconds} (défaut 7 jours)
|
||||
* permettant à une autorité externe de consulter les KPI agrégés sans login.
|
||||
*
|
||||
* @since 2026-04-25 (Sprint 17)
|
||||
*/
|
||||
@Path("/api/admin/kpi/share-link")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Authenticated
|
||||
public class KpiShareLinkResource {
|
||||
|
||||
@Inject KpiShareTokenService tokenService;
|
||||
@Inject AuditTrailService auditTrail;
|
||||
|
||||
@GET
|
||||
@Path("/{orgId}")
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "PRESIDENT", "COMPLIANCE_OFFICER", "SUPER_ADMIN"})
|
||||
public Map<String, Object> generer(
|
||||
@PathParam("orgId") UUID orgId,
|
||||
@QueryParam("ttlSeconds") Long ttlSeconds) {
|
||||
|
||||
long ttl = ttlSeconds == null ? KpiShareTokenService.DEFAULT_TTL_SECONDS : ttlSeconds;
|
||||
String token = tokenService.generer(orgId, ttl);
|
||||
|
||||
auditTrail.logSimple("KpiShareLink", orgId, "CREATE",
|
||||
"Lien KPI public généré (TTL " + ttl + "s)");
|
||||
|
||||
return Map.of(
|
||||
"token", token,
|
||||
"ttlSeconds", ttl,
|
||||
"publicUrl", "/api/public/kpi?token=" + token,
|
||||
"publicWebPath", "/pages/public/kpi?token=" + token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.compliance.response.KpiPublicSnapshot;
|
||||
import dev.lions.unionflow.server.service.compliance.KpiPublicService;
|
||||
import dev.lions.unionflow.server.service.compliance.KpiShareTokenService;
|
||||
import io.quarkus.security.PermissionsAllowed;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.util.UUID;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Endpoint public KPI (Sprint 17) — consommation par autorités externes via token signé.
|
||||
*
|
||||
* <p>Pas de @Authenticated : accès anonyme avec token signé HMAC-SHA256. Toute requête
|
||||
* (succès ou échec) est tracée dans l'audit trail pour transparency.
|
||||
*
|
||||
* @since 2026-04-25 (Sprint 17)
|
||||
*/
|
||||
@Path("/api/public/kpi")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@PermitAll
|
||||
public class PublicKpiResource {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(PublicKpiResource.class);
|
||||
|
||||
@Inject KpiShareTokenService tokenService;
|
||||
@Inject KpiPublicService kpiService;
|
||||
|
||||
@GET
|
||||
public Response consulter(@QueryParam("token") String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(java.util.Map.of("error", "token query param requis"))
|
||||
.build();
|
||||
}
|
||||
var orgIdOpt = tokenService.verifier(token);
|
||||
if (orgIdOpt.isEmpty()) {
|
||||
LOG.warnf("PublicKpi: token invalide ou expiré");
|
||||
return Response.status(Response.Status.UNAUTHORIZED)
|
||||
.entity(java.util.Map.of("error", "Token invalide ou expiré"))
|
||||
.build();
|
||||
}
|
||||
|
||||
UUID orgId = orgIdOpt.get();
|
||||
try {
|
||||
KpiPublicSnapshot snap = kpiService.snapshotPublic(orgId, "PUBLIC_TOKEN");
|
||||
return Response.ok(snap).build();
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOG.warnf("PublicKpi: org %s introuvable", orgId);
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(java.util.Map.of("error", "Organisation introuvable"))
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
LOG.errorf(e, "PublicKpi: erreur snapshot org=%s", orgId);
|
||||
return Response.serverError()
|
||||
.entity(java.util.Map.of("error", "Erreur interne"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package dev.lions.unionflow.server.service.compliance;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.compliance.response.KpiPublicSnapshot;
|
||||
import dev.lions.unionflow.server.security.AuditTrailService;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Service métier KPI public (Sprint 17) — extrait un sous-ensemble safe de
|
||||
* {@link ComplianceDashboardService.ComplianceSnapshot} pour diffusion externe.
|
||||
*
|
||||
* @since 2026-04-25 (Sprint 17)
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class KpiPublicService {
|
||||
|
||||
@Inject ComplianceDashboardService dashboardService;
|
||||
@Inject AuditTrailService auditTrail;
|
||||
|
||||
/**
|
||||
* Construit le snapshot public pour une organisation.
|
||||
* Audit chaque accès public pour traçabilité (transparency méta).
|
||||
*/
|
||||
public KpiPublicSnapshot snapshotPublic(UUID organisationId, String accessSource) {
|
||||
var snap = dashboardService.snapshot(organisationId);
|
||||
|
||||
auditTrail.logSimple("KpiPublicSnapshot", organisationId, "EXPORT",
|
||||
"Accès KPI public via " + (accessSource == null ? "token" : accessSource));
|
||||
|
||||
return KpiPublicSnapshot.builder()
|
||||
.organisationNom(snap.organisationNom())
|
||||
.referentielComptable(snap.referentielComptable() == null ? null
|
||||
: snap.referentielComptable().name())
|
||||
.scoreGlobal(snap.scoreGlobal())
|
||||
.complianceOfficerDesigne(snap.complianceOfficerDesigne())
|
||||
.agAnnuelleStatut(snap.agAnnuelle() == null ? null : snap.agAnnuelle().statut())
|
||||
.rapportAirmsStatut(snap.rapportAirms() == null ? null : snap.rapportAirms().statut())
|
||||
.dirigeantsAvecCmu(snap.dirigeantsAvecCmu())
|
||||
.tauxKycAJourPct(snap.tauxKycAJourPct())
|
||||
.tauxFormationLbcFtPct(snap.tauxFormationLbcFtPct())
|
||||
.couvertureUboPct(snap.couvertureUboPct())
|
||||
.commissaireAuxComptesStatut(snap.commissaireAuxComptes() == null ? null
|
||||
: snap.commissaireAuxComptes().statut())
|
||||
.dateGeneration(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package dev.lions.unionflow.server.service.compliance;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Génération et validation des tokens signés pour partage KPI public (Sprint 17).
|
||||
*
|
||||
* <p>Format token : {@code base64url(orgId)} + {@code .} + {@code base64url(expiryEpochMs)}
|
||||
* + {@code .} + {@code base64url(HMAC-SHA256(orgId.expiryEpochMs))}.
|
||||
*
|
||||
* <p>Vérification : recalcule HMAC, compare en time-constant ({@link MessageDigest#isEqual}),
|
||||
* vérifie expiry. Aucune base de données — token autosuffisant et révocable uniquement
|
||||
* par rotation du secret.
|
||||
*
|
||||
* @since 2026-04-25 (Sprint 17)
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class KpiShareTokenService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(KpiShareTokenService.class);
|
||||
private static final String HMAC_ALGO = "HmacSHA256";
|
||||
static final long DEFAULT_TTL_SECONDS = 7 * 24 * 3600L; // 7 jours
|
||||
|
||||
@ConfigProperty(name = "unionflow.kpi.share.secret",
|
||||
defaultValue = "CHANGE-ME-IN-PROD-32-CHARS-MIN-OK")
|
||||
String secret;
|
||||
|
||||
/**
|
||||
* Génère un token signé pour partage KPI d'une organisation pendant {@code ttlSeconds}.
|
||||
* Si {@code ttlSeconds <= 0}, applique le défaut (7 jours).
|
||||
*/
|
||||
public String generer(UUID organisationId, long ttlSeconds) {
|
||||
if (organisationId == null) throw new IllegalArgumentException("organisationId null");
|
||||
long ttl = ttlSeconds > 0 ? ttlSeconds : DEFAULT_TTL_SECONDS;
|
||||
long expiryMs = Instant.now().plusSeconds(ttl).toEpochMilli();
|
||||
|
||||
String orgEncoded = encode(organisationId.toString());
|
||||
String expEncoded = encode(Long.toString(expiryMs));
|
||||
String payload = orgEncoded + "." + expEncoded;
|
||||
String signature = encode(hmac(payload));
|
||||
return payload + "." + signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie un token. Retourne l'orgId si valide et non expiré.
|
||||
* Sinon {@link Optional#empty()}.
|
||||
*/
|
||||
public Optional<UUID> verifier(String token) {
|
||||
if (token == null || token.isBlank()) return Optional.empty();
|
||||
String[] parts = token.split("\\.");
|
||||
if (parts.length != 3) {
|
||||
LOG.debugf("Token KPI invalide : nombre de parts = %d", parts.length);
|
||||
return Optional.empty();
|
||||
}
|
||||
String orgEncoded = parts[0];
|
||||
String expEncoded = parts[1];
|
||||
String signature = parts[2];
|
||||
|
||||
// Vérifie HMAC en time-constant
|
||||
String expected = encode(hmac(orgEncoded + "." + expEncoded));
|
||||
byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] receivedBytes = signature.getBytes(StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(expectedBytes, receivedBytes)) {
|
||||
LOG.debugf("Token KPI signature invalide");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Vérifie expiry
|
||||
long expiryMs;
|
||||
UUID orgId;
|
||||
try {
|
||||
expiryMs = Long.parseLong(decode(expEncoded));
|
||||
orgId = UUID.fromString(decode(orgEncoded));
|
||||
} catch (Exception e) {
|
||||
LOG.debugf("Token KPI payload corrompu : %s", e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
if (Instant.now().toEpochMilli() > expiryMs) {
|
||||
LOG.debugf("Token KPI expiré (epochMs=%d, now=%d)", expiryMs, Instant.now().toEpochMilli());
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(orgId);
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private byte[] hmac(String data) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC_ALGO);
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGO));
|
||||
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("HMAC-SHA256 indisponible", e);
|
||||
}
|
||||
}
|
||||
|
||||
static String encode(String s) {
|
||||
return Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(s.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
static String encode(byte[] bytes) {
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
static String decode(String s) {
|
||||
return new String(Base64.getUrlDecoder().decode(s), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user