fix(security): audit RBAC complet v3.0 — rôles normalisés, lifecycle, changement mdp mobile
RBAC:
- HealthResource: @PermitAll
- RoleResource: @RolesAllowed ADMIN/SUPER_ADMIN/ADMIN_ORGANISATION class-level
- PropositionAideResource: @RolesAllowed MEMBRE/USER class-level
- AuthCallbackResource: @PermitAll
- EvenementResource: @PermitAll /publics et /test, count restreint
- BackupResource/LogsMonitoringResource/SystemResource: MODERATOR → MODERATEUR
- AnalyticsResource: MANAGER/MEMBER → ADMIN_ORGANISATION/MEMBRE
- RoleConstant.java: constantes de rôles centralisées
Cycle de vie membres:
- MemberLifecycleService: ajouterMembre()/retirerMembre() sur activation/radiation/archivage
- MembreResource: endpoint GET /numero/{numeroMembre}
- MembreService: méthode trouverParNumeroMembre()
Changement mot de passe:
- CompteAdherentResource: endpoint POST /auth/change-password (mobile)
- MembreKeycloakSyncService: changerMotDePasseDirectKeycloak() via API Admin Keycloak directe
- Fallback automatique si lions-user-manager indisponible
Workflow:
- Flyway V17-V23: rôles, types org, formules Option C, lifecycle columns, bareme cotisation
- Nouvelles classes: MemberLifecycleService, OrganisationModuleService, scheduler
- Security: OrganisationContextFilter, OrganisationContextHolder, ModuleAccessFilter
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
package dev.lions.unionflow.server.security;
|
||||
|
||||
import dev.lions.unionflow.server.service.OrganisationModuleService;
|
||||
import jakarta.annotation.Priority;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Priorities;
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import jakarta.ws.rs.container.ContainerRequestFilter;
|
||||
import jakarta.ws.rs.container.ResourceInfo;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Filtre JAX-RS qui applique le contrôle d'accès par module métier.
|
||||
*
|
||||
* <p>S'exécute après l'authentification (AUTHORIZATION + 10) pour laisser
|
||||
* Quarkus OIDC et {@code @RolesAllowed} s'exécuter en premier.
|
||||
*
|
||||
* <p>Le filtre :
|
||||
* <ol>
|
||||
* <li>Détecte l'annotation {@link RequiresModule} sur la méthode ou la classe.</li>
|
||||
* <li>Extrait l'organisation active depuis le header {@code X-Active-Organisation-Id}.</li>
|
||||
* <li>Vérifie via {@link OrganisationModuleService} que le module est activé.</li>
|
||||
* <li>Retourne HTTP 403 avec un message explicite si le module est absent.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Header attendu : {@code X-Active-Organisation-Id: <UUID>}
|
||||
*/
|
||||
@Provider
|
||||
@Priority(Priorities.AUTHORIZATION + 10)
|
||||
public class ModuleAccessFilter implements ContainerRequestFilter {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(ModuleAccessFilter.class);
|
||||
|
||||
/** Nom du header HTTP transportant l'UUID de l'organisation active. */
|
||||
public static final String HEADER_ACTIVE_ORG = "X-Active-Organisation-Id";
|
||||
|
||||
@Inject
|
||||
OrganisationModuleService organisationModuleService;
|
||||
|
||||
@Context
|
||||
ResourceInfo resourceInfo;
|
||||
|
||||
@Override
|
||||
public void filter(ContainerRequestContext requestContext) throws IOException {
|
||||
// 1. Détecter @RequiresModule sur la méthode, puis sur la classe
|
||||
Method method = resourceInfo.getResourceMethod();
|
||||
RequiresModule annotation = method != null ? method.getAnnotation(RequiresModule.class) : null;
|
||||
if (annotation == null && resourceInfo.getResourceClass() != null) {
|
||||
annotation = resourceInfo.getResourceClass().getAnnotation(RequiresModule.class);
|
||||
}
|
||||
|
||||
if (annotation == null) {
|
||||
// Aucune annotation — laisser passer
|
||||
return;
|
||||
}
|
||||
|
||||
String moduleRequis = annotation.value().toUpperCase();
|
||||
|
||||
// 2. Extraire l'organisation active depuis le header
|
||||
String orgIdHeader = requestContext.getHeaderString(HEADER_ACTIVE_ORG);
|
||||
if (orgIdHeader == null || orgIdHeader.isBlank()) {
|
||||
LOG.warnf("@RequiresModule(%s) — header %s absent, accès refusé", moduleRequis, HEADER_ACTIVE_ORG);
|
||||
requestContext.abortWith(buildForbiddenResponse(
|
||||
"Le header " + HEADER_ACTIVE_ORG + " est requis pour accéder à ce module.",
|
||||
moduleRequis));
|
||||
return;
|
||||
}
|
||||
|
||||
UUID organisationId;
|
||||
try {
|
||||
organisationId = UUID.fromString(orgIdHeader.trim());
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOG.warnf("@RequiresModule(%s) — header %s invalide : %s", moduleRequis, HEADER_ACTIVE_ORG, orgIdHeader);
|
||||
requestContext.abortWith(buildForbiddenResponse(
|
||||
"La valeur du header " + HEADER_ACTIVE_ORG + " n'est pas un UUID valide.",
|
||||
moduleRequis));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Vérifier l'activation du module
|
||||
if (!organisationModuleService.isModuleActif(organisationId, moduleRequis)) {
|
||||
String message = annotation.message().isBlank()
|
||||
? "Le module '" + moduleRequis + "' n'est pas activé pour cette organisation."
|
||||
: annotation.message();
|
||||
LOG.infof("@RequiresModule(%s) — module inactif pour l'organisation %s", moduleRequis, organisationId);
|
||||
requestContext.abortWith(buildForbiddenResponse(message, moduleRequis));
|
||||
}
|
||||
}
|
||||
|
||||
private Response buildForbiddenResponse(String message, String module) {
|
||||
return Response.status(Response.Status.FORBIDDEN)
|
||||
.entity(Map.of(
|
||||
"error", "MODULE_NOT_ACTIVE",
|
||||
"message", message,
|
||||
"module", module
|
||||
))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package dev.lions.unionflow.server.security;
|
||||
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||
import io.quarkus.security.identity.SecurityIdentity;
|
||||
import jakarta.annotation.Priority;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Priorities;
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import jakarta.ws.rs.container.ContainerRequestFilter;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Filtre JAX-RS qui résout l'organisation active pour chaque requête.
|
||||
*
|
||||
* <p>Lit le header {@code X-Active-Organisation-Id}, valide que l'utilisateur
|
||||
* connecté est bien membre actif de cette organisation, puis peuple
|
||||
* {@link OrganisationContextHolder} pour les services aval.
|
||||
*
|
||||
* <p>Si le header est absent, le contexte reste non résolu (les endpoints
|
||||
* qui ne nécessitent pas de contexte org continuent de fonctionner normalement).
|
||||
* Si le header est présent mais invalide ou si l'utilisateur n'est pas membre,
|
||||
* la requête est rejetée avec HTTP 403.
|
||||
*
|
||||
* <p>Priorité : AUTHORIZATION + 5 (après auth, avant @RequiresModule à +10).
|
||||
*/
|
||||
@Provider
|
||||
@Priority(Priorities.AUTHORIZATION + 5)
|
||||
public class OrganisationContextFilter implements ContainerRequestFilter {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(OrganisationContextFilter.class);
|
||||
public static final String HEADER_ACTIVE_ORG = ModuleAccessFilter.HEADER_ACTIVE_ORG;
|
||||
|
||||
@Inject
|
||||
OrganisationContextHolder contextHolder;
|
||||
|
||||
@Inject
|
||||
SecurityIdentity securityIdentity;
|
||||
|
||||
@Inject
|
||||
MembreRepository membreRepository;
|
||||
|
||||
@Inject
|
||||
MembreOrganisationRepository membreOrganisationRepository;
|
||||
|
||||
@Inject
|
||||
OrganisationRepository organisationRepository;
|
||||
|
||||
@Override
|
||||
public void filter(ContainerRequestContext requestContext) throws IOException {
|
||||
String orgIdHeader = requestContext.getHeaderString(HEADER_ACTIVE_ORG);
|
||||
|
||||
if (orgIdHeader == null || orgIdHeader.isBlank()) {
|
||||
// Pas de contexte org — les endpoints sans contexte fonctionnent normalement
|
||||
return;
|
||||
}
|
||||
|
||||
// Valider le format UUID
|
||||
UUID organisationId;
|
||||
try {
|
||||
organisationId = UUID.fromString(orgIdHeader.trim());
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOG.warnf("Header %s invalide : %s", HEADER_ACTIVE_ORG, orgIdHeader);
|
||||
requestContext.abortWith(jakarta.ws.rs.core.Response
|
||||
.status(jakarta.ws.rs.core.Response.Status.BAD_REQUEST)
|
||||
.entity(java.util.Map.of("error", "INVALID_ORGANISATION_ID",
|
||||
"message", "Le header X-Active-Organisation-Id n'est pas un UUID valide."))
|
||||
.build());
|
||||
return;
|
||||
}
|
||||
|
||||
// Résoudre l'organisation
|
||||
Optional<Organisation> orgOpt = organisationRepository.findByIdOptional(organisationId);
|
||||
if (orgOpt.isEmpty()) {
|
||||
LOG.warnf("Organisation introuvable : %s", organisationId);
|
||||
requestContext.abortWith(jakarta.ws.rs.core.Response
|
||||
.status(jakarta.ws.rs.core.Response.Status.NOT_FOUND)
|
||||
.entity(java.util.Map.of("error", "ORGANISATION_NOT_FOUND",
|
||||
"message", "Organisation introuvable."))
|
||||
.build());
|
||||
return;
|
||||
}
|
||||
|
||||
Organisation organisation = orgOpt.get();
|
||||
|
||||
// Vérifier l'appartenance (skip pour SUPERADMIN qui a accès à tout)
|
||||
if (!securityIdentity.isAnonymous()) {
|
||||
boolean isSuperAdmin = securityIdentity.getRoles().contains("SUPERADMIN")
|
||||
|| securityIdentity.getRoles().contains("SUPER_ADMIN")
|
||||
|| securityIdentity.getRoles().contains("ADMIN");
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
Optional<Membre> membreOpt = membreRepository.findByEmail(email);
|
||||
|
||||
if (membreOpt.isPresent()) {
|
||||
Optional<MembreOrganisation> membreOrgOpt = membreOrganisationRepository
|
||||
.findByMembreIdAndOrganisationId(membreOpt.get().getId(), organisationId);
|
||||
|
||||
if (membreOrgOpt.isEmpty()) {
|
||||
LOG.warnf("Utilisateur %s n'est pas membre de l'organisation %s", email, organisationId);
|
||||
requestContext.abortWith(jakarta.ws.rs.core.Response
|
||||
.status(jakarta.ws.rs.core.Response.Status.FORBIDDEN)
|
||||
.entity(java.util.Map.of("error", "NOT_MEMBER_OF_ORGANISATION",
|
||||
"message", "Vous n'êtes pas membre de cette organisation."))
|
||||
.build());
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier que le membre est actif
|
||||
MembreOrganisation membreOrg = membreOrgOpt.get();
|
||||
String statut = membreOrg.getStatutMembre() != null
|
||||
? membreOrg.getStatutMembre().name() : "";
|
||||
if (!"ACTIF".equals(statut)) {
|
||||
LOG.warnf("Membre %s statut non-actif (%s) dans l'organisation %s", email, statut, organisationId);
|
||||
requestContext.abortWith(jakarta.ws.rs.core.Response
|
||||
.status(jakarta.ws.rs.core.Response.Status.FORBIDDEN)
|
||||
.entity(java.util.Map.of("error", "MEMBER_NOT_ACTIVE",
|
||||
"message", "Votre adhésion à cette organisation n'est pas active."))
|
||||
.build());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Peupler le contexte
|
||||
contextHolder.setOrganisationId(organisationId);
|
||||
contextHolder.setOrganisation(organisation);
|
||||
contextHolder.setResolved(true);
|
||||
LOG.debugf("Contexte org résolu : %s (%s)", organisation.getNom(), organisationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package dev.lions.unionflow.server.security;
|
||||
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import jakarta.enterprise.context.RequestScoped;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Holder request-scoped contenant l'organisation active résolue pour la requête courante.
|
||||
*
|
||||
* <p>Peuplé par {@link OrganisationContextFilter} à partir du header
|
||||
* {@code X-Active-Organisation-Id}. Utilisé par les services métier pour
|
||||
* scoper toutes les opérations à l'organisation active.
|
||||
*
|
||||
* <p>Exemple d'utilisation dans un service :
|
||||
* <pre>{@code
|
||||
* @Inject OrganisationContextHolder orgContext;
|
||||
*
|
||||
* public List<Tontine> listTontines() {
|
||||
* UUID orgId = orgContext.getOrganisationId();
|
||||
* return tontineRepository.findByOrganisationId(orgId);
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
@RequestScoped
|
||||
public class OrganisationContextHolder {
|
||||
|
||||
private UUID organisationId;
|
||||
private Organisation organisation;
|
||||
private boolean resolved = false;
|
||||
|
||||
public UUID getOrganisationId() {
|
||||
return organisationId;
|
||||
}
|
||||
|
||||
public void setOrganisationId(UUID organisationId) {
|
||||
this.organisationId = organisationId;
|
||||
}
|
||||
|
||||
public Organisation getOrganisation() {
|
||||
return organisation;
|
||||
}
|
||||
|
||||
public void setOrganisation(Organisation organisation) {
|
||||
this.organisation = organisation;
|
||||
}
|
||||
|
||||
public boolean isResolved() {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
public void setResolved(boolean resolved) {
|
||||
this.resolved = resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne true si un contexte d'organisation est disponible.
|
||||
*/
|
||||
public boolean hasContext() {
|
||||
return resolved && organisationId != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package dev.lions.unionflow.server.security;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Annotation déclarative pour le contrôle d'accès par module métier.
|
||||
*
|
||||
* <p>Placée sur un endpoint JAX-RS, elle vérifie que l'organisation active
|
||||
* du contexte de la requête a bien le module requis activé.
|
||||
*
|
||||
* <p>Le module est déterminé par le type d'organisation (Option C) et non
|
||||
* par le plan tarifaire.
|
||||
*
|
||||
* <p>Exemple d'utilisation :
|
||||
* <pre>{@code
|
||||
* @GET
|
||||
* @Path("/cycles")
|
||||
* @RequiresModule("TONTINE")
|
||||
* @RolesAllowed({"ADMIN", "TONTINE_MANAGER"})
|
||||
* public Response getCycles() { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface RequiresModule {
|
||||
|
||||
/**
|
||||
* Nom du module requis (ex: "TONTINE", "CREDIT", "EPARGNE", "AGRICULTURE").
|
||||
* Doit correspondre à l'une des valeurs de {@link dev.lions.unionflow.server.service.OrganisationModuleService#MODULES_COMMUNS}
|
||||
* ou des modules métier définis par type d'organisation.
|
||||
*/
|
||||
String value();
|
||||
|
||||
/**
|
||||
* Message d'erreur personnalisé affiché si le module n'est pas actif.
|
||||
* Par défaut, un message générique est généré.
|
||||
*/
|
||||
String message() default "";
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package dev.lions.unionflow.server.security;
|
||||
|
||||
/**
|
||||
* Constantes centralisées pour les rôles UnionFlow.
|
||||
*
|
||||
* <p>Utiliser ces constantes dans {@code @RolesAllowed} pour éviter
|
||||
* les fautes de frappe et garantir la cohérence entre ressources.</p>
|
||||
*/
|
||||
public final class RoleConstant {
|
||||
|
||||
// ── Rôles système ─────────────────────────────────────────────────────────
|
||||
public static final String SUPER_ADMIN = "SUPER_ADMIN";
|
||||
public static final String ADMIN = "ADMIN";
|
||||
|
||||
// ── Rôles organisation ────────────────────────────────────────────────────
|
||||
public static final String ADMIN_ORGANISATION = "ADMIN_ORGANISATION";
|
||||
public static final String PRESIDENT = "PRESIDENT";
|
||||
public static final String VICE_PRESIDENT = "VICE_PRESIDENT";
|
||||
public static final String SECRETAIRE = "SECRETAIRE";
|
||||
public static final String TRESORIER = "TRESORIER";
|
||||
public static final String MODERATEUR = "MODERATEUR";
|
||||
|
||||
// ── Rôles membres ─────────────────────────────────────────────────────────
|
||||
public static final String MEMBRE = "MEMBRE";
|
||||
public static final String USER = "USER";
|
||||
|
||||
// ── Rôles fonctionnels (événements) ───────────────────────────────────────
|
||||
public static final String ORGANISATEUR_EVENEMENT = "ORGANISATEUR_EVENEMENT";
|
||||
|
||||
// ── Rôles modules spécialisés ─────────────────────────────────────────────
|
||||
public static final String TONTINE_RESP = "TONTINE_RESP";
|
||||
public static final String MUTUELLE_RESP = "MUTUELLE_RESP";
|
||||
public static final String VOTE_RESP = "VOTE_RESP";
|
||||
public static final String COOP_RESP = "COOP_RESP";
|
||||
public static final String ONG_RESP = "ONG_RESP";
|
||||
public static final String CULTE_RESP = "CULTE_RESP";
|
||||
public static final String REGISTRE_RESP = "REGISTRE_RESP";
|
||||
public static final String AGRI_RESP = "AGRI_RESP";
|
||||
public static final String COLLECTE_RESP = "COLLECTE_RESP";
|
||||
|
||||
private RoleConstant() {}
|
||||
}
|
||||
Reference in New Issue
Block a user