Files
unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/WaveCheckoutService.java
dahoud a2dfae9a0b 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
2026-04-07 20:52:26 +00:00

263 lines
12 KiB
Java

package dev.lions.unionflow.server.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.HexFormat;
import java.util.UUID;
/**
* Service d'appel à l'API Wave Checkout (https://docs.wave.com/checkout).
* Conforme à la spec : POST /v1/checkout/sessions, Wave-Signature si secret configuré.
*/
@ApplicationScoped
public class WaveCheckoutService {
private static final Logger LOG = Logger.getLogger(WaveCheckoutService.class);
@ConfigProperty(name = "wave.api.key", defaultValue = "")
String apiKey;
@ConfigProperty(name = "wave.api.base.url", defaultValue = "https://api.wave.com/v1")
String baseUrl;
@ConfigProperty(name = "wave.api.secret", defaultValue = "")
String signingSecret;
@ConfigProperty(name = "wave.redirect.base.url", defaultValue = "http://localhost:8080")
String redirectBaseUrl;
@ConfigProperty(name = "wave.mock.enabled", defaultValue = "false")
boolean mockEnabled;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Crée une session Checkout Wave (spec : POST /v1/checkout/sessions).
*
* @param amount Montant (string, pas de décimales pour XOF)
* @param currency Code ISO 4217 (ex: XOF)
* @param successUrl URL https de redirection après succès
* @param errorUrl URL https de redirection en cas d'erreur
* @param clientRef Référence client optionnelle (max 255 caractères)
* @param restrictMobile Numéro E.164 optionnel pour restreindre le payeur
* @return id de la session (cos-xxx) et wave_launch_url
*/
public WaveCheckoutSessionResponse createSession(
String amount,
String currency,
String successUrl,
String errorUrl,
String clientRef,
String restrictMobile) throws WaveCheckoutException {
boolean useMock = mockEnabled || apiKey == null || apiKey.trim().isBlank();
if (useMock) {
LOG.warn("Wave Checkout en mode MOCK (pas d'appel API Wave)");
return createMockSession(successUrl, clientRef);
}
String base = (baseUrl == null || baseUrl.endsWith("/")) ? baseUrl.replaceAll("/+$", "") : baseUrl;
if (!base.endsWith("/v1")) base = base + "/v1";
String url = base + "/checkout/sessions";
String body = buildRequestBody(amount, currency, successUrl, errorUrl, clientRef, restrictMobile);
try {
long timestamp = System.currentTimeMillis() / 1000;
String waveSignature = null;
if (signingSecret != null && !signingSecret.trim().isBlank()) {
waveSignature = computeWaveSignature(timestamp, body);
}
java.net.http.HttpRequest.Builder requestBuilder = java.net.http.HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
if (waveSignature != null) {
requestBuilder.header("Wave-Signature", "t=" + timestamp + ",v1=" + waveSignature);
}
java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder().build();
java.net.http.HttpResponse<String> response = client.send(
requestBuilder.build(),
java.net.http.HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() >= 400) {
LOG.errorf("Wave Checkout API error: %d %s", response.statusCode(), response.body());
throw new WaveCheckoutException("Wave API: " + response.statusCode() + " " + response.body());
}
JsonNode root = objectMapper.readTree(response.body());
String id = root.has("id") ? root.get("id").asText() : null;
String waveLaunchUrl = root.has("wave_launch_url") ? root.get("wave_launch_url").asText() : null;
if (id == null || waveLaunchUrl == null) {
throw new WaveCheckoutException("Réponse Wave invalide (id ou wave_launch_url manquant)");
}
return new WaveCheckoutSessionResponse(id, waveLaunchUrl);
} catch (WaveCheckoutException e) {
throw e;
} catch (Exception e) {
LOG.error(e.getMessage(), e);
throw new WaveCheckoutException("Erreur appel Wave Checkout: " + e.getMessage(), e);
}
}
private String buildRequestBody(String amount, String currency, String successUrl, String errorUrl,
String clientRef, String restrictMobile) {
StringBuilder sb = new StringBuilder();
sb.append("{\"amount\":\"").append(escapeJson(amount)).append("\"");
sb.append(",\"currency\":\"").append(escapeJson(currency != null ? currency : "XOF")).append("\"");
sb.append(",\"success_url\":\"").append(escapeJson(successUrl)).append("\"");
sb.append(",\"error_url\":\"").append(escapeJson(errorUrl)).append("\"");
if (clientRef != null && !clientRef.isBlank()) {
String ref = clientRef.length() > 255 ? clientRef.substring(0, 255) : clientRef;
sb.append(",\"client_reference\":\"").append(escapeJson(ref)).append("\"");
}
if (restrictMobile != null && !restrictMobile.isBlank()) {
sb.append(",\"restrict_payer_mobile\":\"").append(escapeJson(restrictMobile)).append("\"");
}
sb.append("}");
return sb.toString();
}
private static String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
}
/**
* Spec Wave : payload = timestamp + body (raw string), HMAC-SHA256 avec signing secret.
*/
private String computeWaveSignature(long timestamp, String body) throws NoSuchAlgorithmException, InvalidKeyException {
String payload = timestamp + body;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
}
/**
* Interroge l'état d'une session Wave Checkout (spec : GET /v1/checkout/sessions/:id).
* Utilisé par le polling web pour détecter automatiquement la complétion du paiement.
*
* @param sessionId ID de session Wave (cos-xxx)
* @return statut de la session (checkout_status, payment_status, transaction_id)
*/
public WaveSessionStatusResponse getSession(String sessionId) throws WaveCheckoutException {
boolean useMock = mockEnabled || apiKey == null || apiKey.trim().isBlank();
if (useMock) {
// En mock, on ne peut pas vraiment vérifier — retourner EN_COURS (polling s'arrête via /web-success)
LOG.warnf("Wave getSession en mode MOCK — session %s", sessionId);
return new WaveSessionStatusResponse(sessionId, "open", "processing", null);
}
String base = (baseUrl == null || baseUrl.endsWith("/")) ? baseUrl.replaceAll("/+$", "") : baseUrl;
if (!base.endsWith("/v1")) base = base + "/v1";
String url = base + "/checkout/sessions/" + sessionId;
try {
long timestamp = System.currentTimeMillis() / 1000;
java.net.http.HttpRequest.Builder requestBuilder = java.net.http.HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(15))
.GET();
if (signingSecret != null && !signingSecret.trim().isBlank()) {
String sig = computeWaveSignature(timestamp, "");
requestBuilder.header("Wave-Signature", "t=" + timestamp + ",v1=" + sig);
}
java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder().build();
java.net.http.HttpResponse<String> response = client.send(
requestBuilder.build(),
java.net.http.HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() >= 400) {
throw new WaveCheckoutException("Wave API: " + response.statusCode() + " " + response.body());
}
JsonNode root = objectMapper.readTree(response.body());
String checkoutStatus = root.has("checkout_status") ? root.get("checkout_status").asText() : null;
String paymentStatus = root.has("payment_status") ? root.get("payment_status").asText() : null;
String transactionId = root.has("transaction_id") ? root.get("transaction_id").asText() : null;
return new WaveSessionStatusResponse(sessionId, checkoutStatus, paymentStatus, transactionId);
} catch (WaveCheckoutException e) {
throw e;
} catch (Exception e) {
LOG.error(e.getMessage(), e);
throw new WaveCheckoutException("Erreur vérification session Wave: " + e.getMessage(), e);
}
}
public String getRedirectBaseUrl() {
return (redirectBaseUrl == null || redirectBaseUrl.trim().isBlank()) ? "http://localhost:8080" : redirectBaseUrl.trim();
}
/** Session mock pour tests : wave_launch_url = successUrl pour simuler le retour dans l'app. */
private WaveCheckoutSessionResponse createMockSession(String successUrl, String clientRef) {
String mockId = "cos-mock-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
return new WaveCheckoutSessionResponse(mockId, successUrl);
}
public static final class WaveSessionStatusResponse {
public final String sessionId;
/** "open" | "complete" | "expired" */
public final String checkoutStatus;
/** "processing" | "cancelled" | "succeeded" */
public final String paymentStatus;
/** ID transaction Wave (TCN...) — non-null si succeeded */
public final String transactionId;
public WaveSessionStatusResponse(String sessionId, String checkoutStatus, String paymentStatus, String transactionId) {
this.sessionId = sessionId;
this.checkoutStatus = checkoutStatus;
this.paymentStatus = paymentStatus;
this.transactionId = transactionId;
}
public boolean isSucceeded() {
return "succeeded".equals(paymentStatus) && "complete".equals(checkoutStatus);
}
public boolean isExpired() {
return "expired".equals(checkoutStatus);
}
}
public static final class WaveCheckoutSessionResponse {
public final String id;
public final String waveLaunchUrl;
public WaveCheckoutSessionResponse(String id, String waveLaunchUrl) {
this.id = id;
this.waveLaunchUrl = waveLaunchUrl;
}
}
public static class WaveCheckoutException extends RuntimeException {
public WaveCheckoutException(String message) {
super(message);
}
public WaveCheckoutException(String message, Throwable cause) {
super(message, cause);
}
}
}