From 4ba4aa1862ba44f00b8699523e0f006f8c9ef7e3 Mon Sep 17 00:00:00 2001 From: Bennett Date: Mon, 31 Aug 2026 17:43:57 +0300 Subject: [PATCH 1/2] release: Add connector config for PATs --- README.md | 59 +++++++- docs/deployment.md | 2 + docs/third-party-integration.md | 8 ++ .../entities/auth/PersonalAccessToken.java | 15 ++ .../core/entities/sms/SmsConnector.java | 4 + .../whatsapp/WhatsAppWebhookConfig.java | 62 ++++++++ .../repositories/SmsConnectorRepository.java | 2 + .../core/repositories/SmsLogRepository.java | 3 + .../WhatsAppWebhookConfigRepository.java | 14 ++ .../core/security/ApiTokenContext.java | 15 ++ .../core/security/ConnectorSecretCrypto.java | 48 +++++++ .../security/EncryptedStringConverter.java | 7 + .../security/PatAuthenticationFilter.java | 6 +- .../core/security/SecurityConfig.java | 3 + .../flextuma/core/senders/WhatsAppSender.java | 80 +++++++++++ .../core/services/RateLimiterService.java | 34 +++++ .../controllers/NotificationController.java | 8 ++ .../services/NotificationService.java | 59 ++++++-- .../sms/services/SmsConnectorService.java | 3 +- .../modules/sms/services/SmsLogService.java | 8 +- .../WhatsAppWebhookConfigController.java | 13 ++ .../WhatsAppWebhookController.java | 135 ++++++++++++++++++ .../WhatsAppWebhookConfigService.java | 52 +++++++ src/main/resources/application.properties | 8 ++ .../services/NotificationServiceTest.java | 42 ++++++ .../sms/services/SmsLogServiceTest.java | 10 ++ 26 files changed, 688 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppWebhookConfig.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppWebhookConfigRepository.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/security/ApiTokenContext.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/security/ConnectorSecretCrypto.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/security/EncryptedStringConverter.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookConfigController.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookConfigService.java diff --git a/README.md b/README.md index a9b6873..1ed482c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Flextuma -Flextuma is a configurable, multi-tenant messaging gateway built on Spring Boot. It serves multiple organisations from a single deployment with full data isolation, and supports SMS delivery today with WhatsApp and Email on the roadmap. +Flextuma is a configurable, multi-tenant messaging gateway built on Spring Boot. It serves multiple organisations from a single deployment with full data isolation, and supports SMS and WhatsApp Cloud API delivery today. --- @@ -210,6 +210,63 @@ These are `POST` and `PUT` request samples for each shared CRUD resource; the co For Beem, delivery reports are normally obtained by the polling worker, which uses the submitted `request_id` saved as `providerMessageId`. +### WhatsApp Cloud API + +Create a normal `/api/connectors` record with `provider: "WHATSAPP"`, `key` set to the Meta access token, `senderId` set to the Meta phone-number ID, and `url` set to the Graph API base URL (for example `https://graph.facebook.com/v21.0`). The token is write-only/masked after creation. Send a text message with: + +```json +POST /api/notifications/whatsapp +{ "phoneNumber": "+255700000000", "message": "Hello from WhatsApp" } +``` + +The message is queued and tracked in `/api/smsLogs` alongside SMS; WhatsApp delivery IDs and delivered/read/failed events update that log. + +#### Shared system connectors and safe use + +When a customer does not have an active connector for the selected provider, Flextuma can intentionally fall back to a matching `{PROVIDER}_SYSTEM` connector. This is Flextuma's paid shared infrastructure, not access to another customer's credentials. The send is always attributed to the authenticated user and must debit that user's wallet before a message log is queued. The charge is the configured per-segment price multiplied by the actual segment count. + +Clients may optionally send `connectorId` with a notification request to select an active connector explicitly. Flextuma rejects a non-system connector unless it belongs to the authenticated user, and rejects it if its provider differs from the requested provider. Connector credentials (`key` and `secret`) and WhatsApp verification/app/signing secrets are AES-GCM encrypted in the database when `FLEXTUMA_CONNECTOR_ENCRYPTION_KEY` is configured; credentials remain write-only and masked in all API responses. + +Existing plaintext connector secrets remain readable during a controlled migration. Set the encryption key first, then re-save each connector (or run an approved one-time migration) to encrypt those rows. Do not rotate or remove an encryption key until every record using it has been re-encrypted with the replacement key. + +For API automation, create a scoped personal access token with `scopes: ["MESSAGES_SEND"]`. A scoped token must also list its customer-owned `allowedConnectorIds`, or set `allowSystemConnectors: true` to spend the caller's wallet on Flextuma shared connectors. Tokens created before connector grants were introduced remain legacy role-based tokens; rotate them to a scoped token before enforcing this policy globally. + +To deliver inbound WhatsApp messages and status events into a user's system, create `/api/whatsappWebhookConfigs`: + +```json +{ + "phoneNumberId": "META_PHONE_NUMBER_ID", + "callbackUrl": "https://customer.example.com/webhooks/whatsapp", + "appSecret": "Meta App Secret used to verify inbound signatures", + "signingSecret": "optional-customer-shared-secret" +} +``` + +Set `FLEXTUMA_PUBLIC_BASE_URL` to Flextuma's public HTTPS origin before creating configurations. On creation, Flextuma generates and persists a random `verifyToken` and a unique `metaCallbackUrl`. Copy these two returned values directly into WhatsApp Cloud's callback URL and verify-token fields; users do not need to create or manage secrets for Meta verification. The generated URL binds Meta's request to that user's configuration, and Flextuma also checks the configured `phoneNumberId`. Flextuma validates Meta's `X-Hub-Signature-256` whenever `appSecret` is set, then forwards the original JSON to the owning user's `callbackUrl`. When `signingSecret` is supplied, the forwarded request includes `X-Flextuma-Signature-256: sha256=` and `X-Flextuma-Event: whatsapp`. Callback URLs must be HTTPS. The callback endpoint must return quickly with a 2xx response; failed relays are logged and do not trigger a Meta retry, preventing duplicate downstream processing. + +### Planned: Flextuma-managed Meta Tech Provider onboarding + +The configuration above is the current **bring-your-own-Meta** integration: customers supply their Cloud API credentials and phone-number ID. The planned Tech Provider mode will offer a branded **Connect WhatsApp** flow through Meta Embedded Signup, so customers do not manually handle access tokens, app secrets, callback URLs, or verification tokens. + +| Area | Current bring-your-own-Meta mode | Planned Tech Provider mode | +| --- | --- | --- | +| Customer action | Create a connector and webhook configuration | Complete Meta Embedded Signup inside Flextuma | +| Credentials | Customer enters and rotates them | Flextuma obtains and stores them server-side, encrypted at rest | +| Callback setup | Flextuma generates callback URL/token; customer pastes them into Meta | Flextuma subscribes the WABA and phone number programmatically | +| Webhook validation | Optional per-customer Meta app secret | Platform-owned Meta app secret validation, then signed relay to the customer | +| Sender lifecycle | Customer configures the phone-number ID | Flextuma registers the phone number, maps WABA/number to the tenant, and manages disconnect/revocation | + +#### Required product and platform work + +1. Complete Meta Tech Provider onboarding, business verification, app review, and the required advanced permissions before making this flow generally available. +2. Add an Embedded Signup page that exchanges the short-lived authorization result only on the server; no Meta credential may be exposed to the browser, logs, exports, or API responses. +3. Add tenant-owned `WhatsAppConnection`, `WhatsAppBusinessAccount`, and `WhatsAppPhoneNumber` records. Store WABA ID, phone-number ID, encrypted access-token reference, lifecycle state, and consent/audit timestamps separately from customer webhook-forwarding settings. +4. Register phone numbers and subscribe the connected WABA to Flextuma's platform webhook after signup. Use a single platform callback endpoint, verify every POST using `X-Hub-Signature-256`, and resolve the tenant from the Meta asset IDs rather than an untrusted callback parameter. +5. Add lifecycle handling for token expiry/rotation, Meta permission revocation, number disconnection, quality/rate-limit events, subscription repair, and safe idempotent retries of provisioning calls. +6. Add template synchronization/approval state, customer opt-in evidence, sender-quality visibility, usage/billing boundaries, support tooling, and a customer-controlled disconnect action that revokes access and disables message sends. + +The manually configured mode must remain available during rollout and for customers that use their own Meta application. Do not represent Tech Provider onboarding as implemented until Meta approval and live end-to-end onboarding evidence are available. + ### System administration These routes require `SUPER_ADMIN` or `ALL` authority. diff --git a/docs/deployment.md b/docs/deployment.md index 2351718..113c5c4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -28,6 +28,8 @@ Provide secrets through the platform secret manager, never in the image, reposit | `HIKARI_MAX_POOL`, `HIKARI_MIN_IDLE` | Recommended | Size across all replicas below PostgreSQL’s connection limit. | | `SESSION_TIMEOUT` | Recommended | Session lifetime, e.g. `30m`. | | `SMS_PRICE_PER_SEGMENT` | Yes | Decimal cost used for wallet accounting; confirm the business unit and currency. | +| `FLEXTUMA_CONNECTOR_ENCRYPTION_KEY` | Yes for connector writes | Base64-encoded 32-byte AES key used to encrypt provider keys, secrets, access tokens, and app secrets at rest. Store and rotate it through the deployment secret manager; it must never be committed. | +| `FLEXTUMA_SYSTEM_CONNECTORS_DAILY_MESSAGE_LIMIT_PER_USER` | Optional | Maximum messages per user and shared system connector per UTC day; defaults to `1000`. Set `0` only for an intentionally unlimited plan. | | `FLEXTUMA_SMS_BEEM_DELIVERY_POLL_INTERVAL_MS` | Optional | Beem delivery-report polling interval in milliseconds; defaults to `60000`. Beem polling starts five minutes after send. | | `FLEXTUMA_SMS_BEEM_DELIVERY_MINIMUM_DELAY_MINUTES` | Optional | Minimum wait before the first Beem delivery lookup; defaults to `5`, as recommended by Beem. | | `APP_FRONTEND_DIRECTORY` | If serving UI | Read-only directory containing `index.html` and assets. | diff --git a/docs/third-party-integration.md b/docs/third-party-integration.md index 2a55583..48df74a 100644 --- a/docs/third-party-integration.md +++ b/docs/third-party-integration.md @@ -98,6 +98,14 @@ The trigger endpoint is `POST /api/webhooks/{connector-config-uuid}/sms`; it fet ## Implementation gaps and recommendations +### WhatsApp managed onboarding (planned) + +The current WhatsApp Cloud API implementation is a bring-your-own-Meta integration. It accepts a customer-owned connector and relays webhooks using a Flextuma-generated callback URL and verification token. It is not a Meta Tech Provider / Embedded Signup integration. + +To offer no-secret customer onboarding, Flextuma must first obtain the necessary Meta Tech Provider approvals and advanced permissions. The implementation must then add a server-side Embedded Signup exchange, encrypted tenant-scoped credential storage, WABA and phone-number registration, programmatic webhook subscription, Meta asset-to-tenant routing, token/revocation lifecycle handling, and a customer disconnect path. The browser must never receive persistent Meta credentials or the platform app secret. + +Until that work is complete, user-facing copy must state that the customer configures their own Meta app and must paste the generated Flextuma callback URL and verification token into WhatsApp Cloud. + These are code-observed findings as of this repository revision, ordered by impact. | Priority | Finding | Impact and recommended action | diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java index 0d92a67..928151c 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java @@ -10,6 +10,10 @@ import lombok.Setter; import java.time.LocalDateTime; +import java.util.Set; +import java.util.UUID; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; @@ -42,6 +46,17 @@ public class PersonalAccessToken extends BaseEntity { private LocalDateTime expiresAt; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "scopes") + private Set scopes; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "allowed_connector_ids") + private Set allowedConnectorIds; + + @Column(name = "allow_system_connectors") + private Boolean allowSystemConnectors; + @Transient private String rawToken; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java index 32b0816..fc6eb7b 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.flexcodelabs.flextuma.core.helpers.MaskingUtil; import com.flexcodelabs.flextuma.core.entities.base.Owner; +import com.flexcodelabs.flextuma.core.security.EncryptedStringConverter; import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; import lombok.*; @@ -30,10 +31,12 @@ public class SmsConnector extends Owner { @NotBlank(message = "Provider name is required") private String provider; + /** For WhatsApp use a Graph API base URL, e.g. https://graph.facebook.com/v21.0. */ @NotBlank(message = "Url is required") private String url; @Column(nullable = true) + @Convert(converter = EncryptedStringConverter.class) @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String key; @@ -41,6 +44,7 @@ public class SmsConnector extends Owner { private Boolean isDefault = true; @Column(nullable = true) + @Convert(converter = EncryptedStringConverter.class) @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String secret; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppWebhookConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppWebhookConfig.java new file mode 100644 index 0000000..bdb98c7 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppWebhookConfig.java @@ -0,0 +1,62 @@ +package com.flexcodelabs.flextuma.core.entities.whatsapp; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.flexcodelabs.flextuma.core.entities.base.Owner; +import com.flexcodelabs.flextuma.core.helpers.MaskingUtil; +import com.flexcodelabs.flextuma.core.security.EncryptedStringConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "whatsapp_webhook_config", uniqueConstraints = @UniqueConstraint(name = "uk_whatsapp_phone_number_id", columnNames = "phone_number_id")) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor +public class WhatsAppWebhookConfig extends Owner { + public static final String PLURAL = "whatsappWebhookConfigs"; + public static final String NAME_PLURAL = "WhatsApp Webhook Configurations"; + public static final String NAME_SINGULAR = "WhatsApp Webhook Configuration"; + public static final String ALL = "ALL"; + public static final String READ = ALL, ADD = ALL, DELETE = ALL, UPDATE = ALL; + + @NotBlank + @Column(name = "phone_number_id", nullable = false) + private String phoneNumberId; + + @NotBlank + @Column(name = "callback_url", nullable = false, columnDefinition = "TEXT") + private String callbackUrl; + + @NotBlank + @Column(name = "verify_token", nullable = false) + @jakarta.persistence.Convert(converter = EncryptedStringConverter.class) + private String verifyToken; + + @JsonIgnore + @Column(name = "callback_token", nullable = false, unique = true) + private String callbackToken; + + /** The unique Meta callback URL generated by Flextuma; copy this to WhatsApp Cloud. */ + @Column(name = "meta_callback_url", nullable = false, columnDefinition = "TEXT") + private String metaCallbackUrl; + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @Column(name = "signing_secret") + @jakarta.persistence.Convert(converter = EncryptedStringConverter.class) + private String signingSecret; + + /** Meta App Secret used to validate X-Hub-Signature-256 on inbound events. */ + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @Column(name = "app_secret") + @jakarta.persistence.Convert(converter = EncryptedStringConverter.class) + private String appSecret; + + @JsonProperty("signingSecret") public String getMaskedSigningSecret() { return MaskingUtil.mask(signingSecret); } + @JsonProperty("appSecret") public String getMaskedAppSecret() { return MaskingUtil.mask(appSecret); } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsConnectorRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsConnectorRepository.java index 7a5b531..f97a9d8 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsConnectorRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsConnectorRepository.java @@ -19,4 +19,6 @@ public interface SmsConnectorRepository extends BaseRepository findByProviderAndCode(String provider, String code); + Optional findByIdAndActiveTrue(UUID id); + } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java index da60395..6e32712 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java @@ -14,6 +14,7 @@ import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; @Repository @@ -47,6 +48,8 @@ int claimPendingMessage(@org.springframework.data.repository.query.Param("id") U long countByCreatedByAndStatusInAndCreatedGreaterThanEqual(User user, Collection statuses, LocalDateTime created); + long countByCreatedByAndConnectorAndCreatedGreaterThanEqual(User user, SmsConnector connector, LocalDateTime created); + long countByStatus(SmsLogStatus status); long countByStatusIn(Collection statuses); diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppWebhookConfigRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppWebhookConfigRepository.java new file mode 100644 index 0000000..2337c9f --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppWebhookConfigRepository.java @@ -0,0 +1,14 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface WhatsAppWebhookConfigRepository extends BaseRepository, JpaSpecificationExecutor { + Optional findByPhoneNumberIdAndActiveTrue(String phoneNumberId); + Optional findByVerifyTokenAndActiveTrue(String verifyToken); + Optional findByCallbackTokenAndActiveTrue(String callbackToken); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/ApiTokenContext.java b/src/main/java/com/flexcodelabs/flextuma/core/security/ApiTokenContext.java new file mode 100644 index 0000000..010b4b3 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/ApiTokenContext.java @@ -0,0 +1,15 @@ +package com.flexcodelabs.flextuma.core.security; +import java.util.Set; +import java.util.UUID; +public final class ApiTokenContext { + public static final String SEND_MESSAGES = "MESSAGES_SEND"; + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + private ApiTokenContext() { } + public static void set(TokenGrant grant) { CURRENT.set(grant); } + public static TokenGrant get() { return CURRENT.get(); } + public static void clear() { CURRENT.remove(); } + public record TokenGrant(Set scopes, Set connectorIds, boolean allowSystemConnectors) { + public boolean allows(String scope) { return scopes != null && scopes.contains(scope); } + public boolean allowsConnector(UUID connectorId) { return connectorIds != null && connectorIds.contains(connectorId); } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/ConnectorSecretCrypto.java b/src/main/java/com/flexcodelabs/flextuma/core/security/ConnectorSecretCrypto.java new file mode 100644 index 0000000..05e146f --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/ConnectorSecretCrypto.java @@ -0,0 +1,48 @@ +package com.flexcodelabs.flextuma.core.security; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; + +/** Encrypts provider credentials at rest. Key material is supplied by the deployment, never the database. */ +@Slf4j @Component +public class ConnectorSecretCrypto { + private static final String PREFIX = "enc:v1:"; + private static final SecureRandom RANDOM = new SecureRandom(); + private static volatile SecretKeySpec encryptionKey; + @Value("${flextuma.connector-secrets.encryption-key:}") private String configuredKey; + + @PostConstruct void configure() { + if (configuredKey == null || configuredKey.isBlank()) { log.warn("Connector credential encryption is not configured. New connector secrets will be rejected until FLEXTUMA_CONNECTOR_ENCRYPTION_KEY is set."); return; } + byte[] key = Base64.getDecoder().decode(configuredKey); + if (key.length != 32) throw new IllegalStateException("FLEXTUMA_CONNECTOR_ENCRYPTION_KEY must be a base64-encoded 32-byte key"); + encryptionKey = new SecretKeySpec(key, "AES"); + } + public static String encrypt(String value) { + if (value == null || value.isBlank() || value.startsWith(PREFIX)) return value; + try { + byte[] iv = new byte[12]; RANDOM.nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, requireKey(), new GCMParameterSpec(128, iv)); + byte[] encrypted = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8)); byte[] combined = new byte[iv.length + encrypted.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length); + return PREFIX + Base64.getEncoder().encodeToString(combined); + } catch (Exception e) { throw new IllegalStateException("Unable to encrypt connector credential", e); } + } + public static String decrypt(String value) { + if (value == null || !value.startsWith(PREFIX)) return value; // Existing plaintext rows remain readable until re-saved. + try { + byte[] combined = Base64.getDecoder().decode(value.substring(PREFIX.length())); byte[] iv = Arrays.copyOfRange(combined, 0, 12); byte[] encrypted = Arrays.copyOfRange(combined, 12, combined.length); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.DECRYPT_MODE, requireKey(), new GCMParameterSpec(128, iv)); + return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + } catch (Exception e) { throw new IllegalStateException("Unable to decrypt connector credential", e); } + } + private static SecretKeySpec requireKey() { if (encryptionKey == null) throw new IllegalStateException("FLEXTUMA_CONNECTOR_ENCRYPTION_KEY must be configured before storing connector credentials"); return encryptionKey; } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/EncryptedStringConverter.java b/src/main/java/com/flexcodelabs/flextuma/core/security/EncryptedStringConverter.java new file mode 100644 index 0000000..6f22efa --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/EncryptedStringConverter.java @@ -0,0 +1,7 @@ +package com.flexcodelabs.flextuma.core.security; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +@Converter public class EncryptedStringConverter implements AttributeConverter { + @Override public String convertToDatabaseColumn(String value) { return ConnectorSecretCrypto.encrypt(value); } + @Override public String convertToEntityAttribute(String value) { return ConnectorSecretCrypto.decrypt(value); } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java b/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java index f63b838..4083c87 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java @@ -58,13 +58,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); + if (pat.getScopes() != null) { + ApiTokenContext.set(new ApiTokenContext.TokenGrant(pat.getScopes(), pat.getAllowedConnectorIds(), Boolean.TRUE.equals(pat.getAllowSystemConnectors()))); + } + pat.setLastUsedAt(LocalDateTime.now()); patRepository.save(pat); } } } - filterChain.doFilter(request, response); + try { filterChain.doFilter(request, response); } finally { ApiTokenContext.clear(); } } private String hashToken(String token) { diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java index 3b0fe9c..8dd507b 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java @@ -52,6 +52,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { .authorizeHttpRequests(auth -> auth .requestMatchers("/api/login").permitAll() .requestMatchers("/api/register").permitAll() + .requestMatchers(org.springframework.http.HttpMethod.GET, "/api/webhooks/whatsapp").permitAll() + .requestMatchers(org.springframework.http.HttpMethod.GET, "/api/webhooks/whatsapp/**").permitAll() + .requestMatchers(org.springframework.http.HttpMethod.POST, "/api/webhooks/whatsapp/**").permitAll() .requestMatchers(org.springframework.http.HttpMethod.POST, "/api/webhooks/*").permitAll() .requestMatchers(org.springframework.http.HttpMethod.GET, "/api/public/**").permitAll() .requestMatchers("/").permitAll() diff --git a/src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java b/src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java new file mode 100644 index 0000000..9ff5706 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java @@ -0,0 +1,80 @@ +package com.flexcodelabs.flextuma.core.senders; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.services.SmsSendResult; +import com.flexcodelabs.flextuma.core.services.SmsSender; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** WhatsApp Cloud API text-message sender. The connector key is a Meta access token. */ +@Service +@RequiredArgsConstructor +public class WhatsAppSender implements SmsSender { + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Override + public String getProvider() { + return "WHATSAPP"; + } + + @Override + public SmsSendResult sendSms(SmsConnector config, String to, String message) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(config.getKey()); + + Map body = new LinkedHashMap<>(); + body.put("messaging_product", "whatsapp"); + body.put("to", normaliseRecipient(to)); + body.put("type", "text"); + body.put("text", Map.of("body", message)); + + ResponseEntity response = restTemplate.postForEntity(messageUrl(config), + new HttpEntity<>(body, headers), Map.class); + Map responseBody = objectMapper.convertValue(response.getBody(), new TypeReference<>() {}); + String messageId = extractMessageId(responseBody); + if (response.getStatusCode().is2xxSuccessful() && messageId != null) { + return SmsSendResult.success("WhatsApp message accepted", messageId, responseBody); + } + return SmsSendResult.failure("WhatsApp API did not return a message id", + String.valueOf(response.getStatusCode().value()), responseBody); + } catch (Exception e) { + return SmsSendResult.failure("Failed to send WhatsApp message: " + e.getMessage(), "SEND_ERROR", + Map.of("error", e.getMessage())); + } + } + + private String messageUrl(SmsConnector config) { + String base = config.getUrl().replaceAll("/$", ""); + if (base.contains("{phoneNumberId}")) { + return base.replace("{phoneNumberId}", config.getSenderId()); + } + return base + "/" + config.getSenderId() + "/messages"; + } + + private String normaliseRecipient(String recipient) { + return recipient == null ? null : recipient.replaceAll("[^0-9]", ""); + } + + @SuppressWarnings("unchecked") + private String extractMessageId(Map body) { + if (body == null || !(body.get("messages") instanceof java.util.List messages) || messages.isEmpty() + || !(messages.get(0) instanceof Map message)) { + return null; + } + Object id = message.get("id"); + return id == null ? null : id.toString(); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/RateLimiterService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/RateLimiterService.java index da1b576..2233362 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/services/RateLimiterService.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/RateLimiterService.java @@ -6,6 +6,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.server.ResponseStatusException; import java.time.Duration; @@ -18,6 +20,12 @@ public class RateLimiterService { private final Map buckets = new ConcurrentHashMap<>(); + private volatile StringRedisTemplate redisTemplate; + + @Autowired(required = false) + void setRedisTemplate(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } private Bucket createNewBucket(UUID tenantId) { Bandwidth limit = Bandwidth.builder() @@ -32,6 +40,9 @@ public void checkRateLimit(UUID tenantId) { return; } + if (checkRedisRateLimit(tenantId)) { + return; + } Bucket bucket = buckets.computeIfAbsent(tenantId, this::createNewBucket); if (!bucket.tryConsume(1)) { @@ -40,4 +51,27 @@ public void checkRateLimit(UUID tenantId) { "Rate limit exceeded. Please try again later."); } } + + private boolean checkRedisRateLimit(UUID tenantId) { + if (redisTemplate == null) { + return false; + } + try { + String key = "flextuma:rate:messages:" + tenantId + ":" + (System.currentTimeMillis() / 1000); + Long count = redisTemplate.opsForValue().increment(key); + if (count != null && count == 1) { + redisTemplate.expire(key, Duration.ofSeconds(2)); + } + if (count != null && count > 10) { + throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, + "Rate limit exceeded. Please try again later."); + } + return true; + } catch (ResponseStatusException e) { + throw e; + } catch (Exception e) { + log.warn("Redis rate limiter unavailable; using local fallback: {}", e.getMessage()); + return false; + } + } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java index b18ded1..7ce6432 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java @@ -49,4 +49,12 @@ public ResponseEntity sendRaw( return ResponseEntity.ok(log); } + + /** Queues a WhatsApp Cloud API text message using the caller's WHATSAPP connector. */ + @PostMapping("/whatsapp") + public ResponseEntity sendWhatsApp(@RequestBody Map payload, + java.security.Principal principal) { + payload.put("provider", "WHATSAPP"); + return ResponseEntity.ok(notificationService.queueRawSms(payload, principal.getName())); + } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index 43349a9..92a3606 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -24,6 +24,7 @@ import com.flexcodelabs.flextuma.core.services.EntityResponseInitializer; import com.flexcodelabs.flextuma.modules.finance.services.WalletService; import com.flexcodelabs.flextuma.core.services.RateLimiterService; +import com.flexcodelabs.flextuma.core.security.ApiTokenContext; import java.util.UUID; import org.springframework.beans.factory.annotation.Value; @@ -48,6 +49,9 @@ public class NotificationService { @Value("${flextuma.sms.price-per-segment:1.0}") private BigDecimal pricePerSegment; + @Value("${flextuma.system-connectors.daily-message-limit-per-user:1000}") + private long systemConnectorDailyMessageLimit; + @Transactional public SmsLog queueTemplatedSms(Map placeholders, String username) { User currentUser = getUser(username); @@ -61,7 +65,7 @@ public SmsLog queueTemplatedSms(Map placeholders, String usernam .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Template not found or you don't have access to it")); - SmsConnector connector = getConnector(currentUser, providerValue); + SmsConnector connector = getConnector(currentUser, providerValue, placeholders.get("connectorId")); String finalMessage = TemplateUtils.fillTemplate(template.getContent(), placeholders); @@ -82,7 +86,7 @@ public SmsLog queueRawSms(Map payload, String username) { "Message contains unreplaced template variables. Please ensure all variables like {{variable}} are properly replaced."); } - SmsConnector connector = getConnector(currentUser, providerValue); + SmsConnector connector = getConnector(currentUser, providerValue, payload.get("connectorId")); return processAndSaveSms(currentUser, connector, phoneNumber, content, null, payload); } @@ -113,25 +117,54 @@ private boolean containsUnreplacedVariables(String content) { return matcher.find(); } - private SmsConnector getConnector(User user, String provider) { + private SmsConnector getConnector(User user, String provider, String connectorId) { + if (connectorId != null && !connectorId.isBlank()) { + SmsConnector connector; + try { + connector = connectorRepository.findByIdAndActiveTrue(UUID.fromString(connectorId)) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Active connector not found")); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "connectorId must be a UUID"); + } + if (!provider.equalsIgnoreCase(connector.getProvider())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "connectorId does not match provider"); + if (!isSystemConnector(connector) && (connector.getCreatedBy() == null || !connector.getCreatedBy().getId().equals(user.getId()))) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You do not have access to this connector"); + enforceTokenConnectorGrant(connector); + return connector; + } Optional connector = connectorRepository.findByCreatedByAndProviderAndActiveTrue(user, provider); if (connector.isPresent()) { + enforceTokenConnectorGrant(connector.get()); return connector.get(); } - return connectorRepository.findByProviderAndCode(provider, provider + "_SYSTEM") + SmsConnector systemConnector = connectorRepository.findByProviderAndCode(provider, provider + "_SYSTEM") .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "No active SMS connector found for provider [" + provider + "]")); + enforceTokenConnectorGrant(systemConnector); + return systemConnector; + } + + private boolean isSystemConnector(SmsConnector connector) { + return connector.getCode() != null && (connector.getProvider() + "_SYSTEM").equalsIgnoreCase(connector.getCode()); + } + + private void enforceTokenConnectorGrant(SmsConnector connector) { + ApiTokenContext.TokenGrant grant = ApiTokenContext.get(); + if (grant == null) return; + if (!grant.allows(ApiTokenContext.SEND_MESSAGES)) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "API token is not allowed to send messages"); + if (isSystemConnector(connector)) { + if (!grant.allowSystemConnectors()) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "API token is not allowed to use shared system connectors"); + } else if (!grant.allowsConnector(connector.getId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "API token is not allowed to use this connector"); } private SmsLog processAndSaveSms(User user, SmsConnector connector, String phoneNumber, String content, SmsTemplate template, Map metadata) { SmsSegmentResult segmentResult = segmentCalculator.calculate(content); - if (connector.getCode() != null && connector.getCode().equals(connector.getProvider() + "_SYSTEM")) { - BigDecimal cost = BigDecimal.valueOf(Math.ceil(BigDecimal.valueOf(segmentResult.segments()) - .divide(pricePerSegment).doubleValue())); - walletService.debit(user, cost, "SMS send to " + phoneNumber, null); + if (isSystemConnector(connector)) { + enforceSystemConnectorDailyLimit(user, connector); + BigDecimal cost = pricePerSegment.multiply(BigDecimal.valueOf(segmentResult.segments())); + walletService.debit(user, cost, "System connector " + connector.getProvider() + " send to " + phoneNumber, null); } SmsLog log = new SmsLog(); @@ -155,4 +188,14 @@ private SmsLog processAndSaveSms(User user, SmsConnector connector, String phone entityResponseInitializer.initialize(savedLog); return savedLog; } + + private void enforceSystemConnectorDailyLimit(User user, SmsConnector connector) { + if (systemConnectorDailyMessageLimit <= 0) return; + long sendsToday = logRepository.countByCreatedByAndConnectorAndCreatedGreaterThanEqual(user, connector, + java.time.LocalDate.now().atStartOfDay()); + if (sendsToday >= systemConnectorDailyMessageLimit) { + throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, + "Daily system connector message limit reached"); + } + } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java index 12968e5..1c60aee 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java @@ -82,7 +82,8 @@ protected SmsConnector onPreUpdate(SmsConnector newEntity, SmsConnector oldEntit private void validateProviderConfig(SmsConnector entity) { String provider = entity.getProvider(); - if ("BEEM".equalsIgnoreCase(provider) || "NEXT".equalsIgnoreCase(provider)) { + if ("BEEM".equalsIgnoreCase(provider) || "NEXT".equalsIgnoreCase(provider) + || "WHATSAPP".equalsIgnoreCase(provider)) { if (entity.getUrl() == null || entity.getUrl().isBlank()) { throw new IllegalArgumentException("URL is required for " + provider); } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java index 74f1e9e..ceec87a 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java @@ -4,6 +4,7 @@ import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.services.BaseService; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,9 +16,11 @@ public class SmsLogService extends BaseService { private final SmsLogRepository smsLogRepository; + private final CurrentUserResolver currentUserResolver; - public SmsLogService(SmsLogRepository repository) { + public SmsLogService(SmsLogRepository repository, CurrentUserResolver currentUserResolver) { this.smsLogRepository = repository; + this.currentUserResolver = currentUserResolver; } @Override @@ -92,6 +95,9 @@ public SmsLog retryFailedMessage(UUID id) { SmsLog log = smsLogRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "SMS log not found")); + boolean ownsLog = currentUserResolver.getCurrentUser().map(user -> log.getCreatedBy() != null && user.getId().equals(log.getCreatedBy().getId())).orElse(false); + if (!Boolean.TRUE.equals(isAdminPermission()) && !ownsLog) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You do not have access to this SMS log"); + if (log.getStatus() != SmsLogStatus.FAILED) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Only failed messages can be retried"); } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookConfigController.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookConfigController.java new file mode 100644 index 0000000..7587705 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookConfigController.java @@ -0,0 +1,13 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.controllers; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; +import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppWebhookConfigService; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/" + WhatsAppWebhookConfig.PLURAL) +public class WhatsAppWebhookConfigController extends BaseController { + public WhatsAppWebhookConfigController(WhatsAppWebhookConfigService service) { super(service); } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java new file mode 100644 index 0000000..25348ab --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java @@ -0,0 +1,135 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.controllers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestTemplate; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** Meta Cloud API webhook endpoint. Events are relayed unchanged to the owning user's callback URL. */ +@Slf4j +@RestController +@RequestMapping("/api/webhooks/whatsapp") +@RequiredArgsConstructor +public class WhatsAppWebhookController { + private final WhatsAppWebhookConfigRepository configRepository; + private final SmsLogRepository smsLogRepository; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @GetMapping + public ResponseEntity verify(@RequestParam("hub.mode") String mode, + @RequestParam("hub.verify_token") String verifyToken, + @RequestParam("hub.challenge") String challenge) { + if ("subscribe".equals(mode) && configRepository.findByVerifyTokenAndActiveTrue(verifyToken).isPresent()) return ResponseEntity.ok(challenge); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + @GetMapping("/{callbackToken}") + public ResponseEntity verifyGeneratedCallback(@PathVariable String callbackToken, + @RequestParam("hub.mode") String mode, @RequestParam("hub.verify_token") String verifyToken, + @RequestParam("hub.challenge") String challenge) { + Optional config = configRepository.findByCallbackTokenAndActiveTrue(callbackToken); + if (config.isPresent() && "subscribe".equals(mode) && MessageDigest.isEqual( + config.get().getVerifyToken().getBytes(StandardCharsets.UTF_8), verifyToken.getBytes(StandardCharsets.UTF_8))) return ResponseEntity.ok(challenge); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + @PostMapping + public ResponseEntity receive(@RequestBody String rawPayload, + @RequestHeader(value = "X-Hub-Signature-256", required = false) String signature) { + Map payload; + try { + payload = objectMapper.readValue(rawPayload, Map.class); + } catch (Exception e) { + return ResponseEntity.badRequest().build(); + } + return handle(payload, rawPayload, signature, phoneNumberId(payload).flatMap(configRepository::findByPhoneNumberIdAndActiveTrue)); + } + + @PostMapping("/{callbackToken}") + public ResponseEntity receiveGeneratedCallback(@PathVariable String callbackToken, @RequestBody String rawPayload, + @RequestHeader(value = "X-Hub-Signature-256", required = false) String signature) { + try { + Map payload = objectMapper.readValue(rawPayload, Map.class); + Optional config = configRepository.findByCallbackTokenAndActiveTrue(callbackToken); + if (config.isPresent() && !config.get().getPhoneNumberId().equals(phoneNumberId(payload).orElse(null))) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return handle(payload, rawPayload, signature, config); + } catch (Exception e) { return ResponseEntity.badRequest().build(); } + } + + private ResponseEntity handle(Map payload, String rawPayload, String signature, Optional config) { + if (config.isEmpty()) { log.warn("Ignoring WhatsApp webhook with no active configuration"); return ResponseEntity.ok().build(); } + if (!validMetaSignature(config.get(), rawPayload, signature)) { log.warn("Rejecting WhatsApp webhook with an invalid Meta signature for config [{}]", config.get().getId()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } + updateDeliveryStatus(payload); relay(config.get(), payload); return ResponseEntity.ok().build(); + } + + private Optional phoneNumberId(Map payload) { + return changes(payload).stream().map(change -> nestedMap(change, "value")) + .map(value -> nestedMap(value, "metadata")).map(metadata -> metadata.get("phone_number_id")) + .filter(value -> value != null && !value.toString().isBlank()).map(Object::toString).findFirst(); + } + + private void updateDeliveryStatus(Map payload) { + for (Map change : changes(payload)) { + Object statuses = nestedMap(change, "value").get("statuses"); + if (!(statuses instanceof List list)) continue; + for (Object status : list) { + if (!(status instanceof Map raw)) continue; + Object id = raw.get("id"), value = raw.get("status"); + if (id != null && value != null) smsLogRepository.findByProviderMessageId(id.toString()).ifPresent(log -> applyStatus(log, value.toString())); + } + } + } + + private void applyStatus(SmsLog logEntry, String status) { + if ("delivered".equalsIgnoreCase(status) || "read".equalsIgnoreCase(status)) logEntry.setStatus(SmsLogStatus.DELIVERED); + else if ("failed".equalsIgnoreCase(status)) logEntry.setStatus(SmsLogStatus.FAILED); + else if ("sent".equalsIgnoreCase(status)) logEntry.setStatus(SmsLogStatus.SENT); + else return; + smsLogRepository.save(logEntry); + } + + private void relay(WhatsAppWebhookConfig config, Map payload) { + try { + String json = objectMapper.writeValueAsString(payload); + HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("X-Flextuma-Event", "whatsapp"); + if (config.getSigningSecret() != null && !config.getSigningSecret().isBlank()) headers.set("X-Flextuma-Signature-256", "sha256=" + hmac(json, config.getSigningSecret())); + restTemplate.postForEntity(config.getCallbackUrl(), new HttpEntity<>(json, headers), Void.class); + } catch (Exception e) { log.warn("Unable to relay WhatsApp webhook for config [{}]: {}", config.getId(), e.getMessage()); } + } + + private String hmac(String payload, String secret) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + StringBuilder result = new StringBuilder(); for (byte b : mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))) result.append(String.format("%02x", b)); return result.toString(); + } + + private boolean validMetaSignature(WhatsAppWebhookConfig config, String rawPayload, String signature) { + if (config.getAppSecret() == null || config.getAppSecret().isBlank()) return true; + if (signature == null || !signature.startsWith("sha256=")) return false; + try { + String expected = "sha256=" + hmac(rawPayload, config.getAppSecret()); + return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), signature.getBytes(StandardCharsets.US_ASCII)); + } catch (Exception e) { return false; } + } + + @SuppressWarnings("unchecked") private Map nestedMap(Map source, String key) { Object value = source.get(key); return value instanceof Map map ? (Map) map : Map.of(); } + @SuppressWarnings("unchecked") private List> changes(Map payload) { + Object entries = payload.get("entry"); if (!(entries instanceof List entryList)) return List.of(); + return entryList.stream().filter(Map.class::isInstance).flatMap(entry -> { Object values = ((Map) entry).get("changes"); return values instanceof List list ? list.stream() : java.util.stream.Stream.empty(); }).filter(Map.class::isInstance).map(item -> (Map) item).toList(); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookConfigService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookConfigService.java new file mode 100644 index 0000000..189ef14 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookConfigService.java @@ -0,0 +1,52 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services; + +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Service; +import java.net.URI; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import com.flexcodelabs.flextuma.core.helpers.TokenGenerator; + +@Service @RequiredArgsConstructor +public class WhatsAppWebhookConfigService extends BaseService { + private final WhatsAppWebhookConfigRepository repository; + @Value("${flextuma.public-base-url:}") private String publicBaseUrl; + protected JpaRepository getRepository() { return repository; } + protected JpaSpecificationExecutor getRepositoryAsExecutor() { return repository; } + protected String getReadPermission() { return WhatsAppWebhookConfig.READ; } + protected String getAddPermission() { return WhatsAppWebhookConfig.ADD; } + protected String getUpdatePermission() { return WhatsAppWebhookConfig.UPDATE; } + protected String getDeletePermission() { return WhatsAppWebhookConfig.DELETE; } + public String getEntityPlural() { return WhatsAppWebhookConfig.NAME_PLURAL; } + protected String getEntitySingular() { return WhatsAppWebhookConfig.NAME_SINGULAR; } + public String getPropertyName() { return WhatsAppWebhookConfig.PLURAL; } + protected String getTableName() { return "whatsapp_webhook_config"; } + + @Override protected void onPreSave(WhatsAppWebhookConfig entity) { provisionMetaCallback(entity); validate(entity); } + @Override protected WhatsAppWebhookConfig onPreUpdate(WhatsAppWebhookConfig entity, WhatsAppWebhookConfig old) { + // Meta-facing values are owned by Flextuma, rather than being supplied or overwritten by a client update. + entity.setVerifyToken(old.getVerifyToken()); + entity.setCallbackToken(old.getCallbackToken()); + entity.setMetaCallbackUrl(old.getMetaCallbackUrl()); + if (entity.getSigningSecret() != null && entity.getSigningSecret().contains("****")) entity.setSigningSecret(old.getSigningSecret()); + if (entity.getAppSecret() != null && entity.getAppSecret().contains("****")) entity.setAppSecret(old.getAppSecret()); + WhatsAppWebhookConfig merged = super.onPreUpdate(entity, old); validate(merged); return merged; + } + private void provisionMetaCallback(WhatsAppWebhookConfig entity) { + if (publicBaseUrl == null || publicBaseUrl.isBlank()) throw new IllegalStateException("FLEXTUMA_PUBLIC_BASE_URL must be configured before WhatsApp webhooks can be created"); + entity.setVerifyToken(TokenGenerator.generateSecureToken(32)); + entity.setCallbackToken(UUID.randomUUID().toString().replace("-", "")); + entity.setMetaCallbackUrl(publicBaseUrl.replaceAll("/+$", "") + "/api/webhooks/whatsapp/" + entity.getCallbackToken()); + } + private void validate(WhatsAppWebhookConfig entity) { + try { + URI uri = URI.create(entity.getCallbackUrl()); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null) throw new IllegalArgumentException(); + } catch (Exception e) { throw new IllegalArgumentException("callbackUrl must be an absolute HTTPS URL"); } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a7a1c8e..d3785cb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -56,6 +56,14 @@ flextuma.sms.price-per-segment=${SMS_PRICE_PER_SEGMENT:20.0} flextuma.sms.beem.delivery-poll-interval-ms=${FLEXTUMA_SMS_BEEM_DELIVERY_POLL_INTERVAL_MS:60000} flextuma.sms.beem.delivery-minimum-delay-minutes=${FLEXTUMA_SMS_BEEM_DELIVERY_MINIMUM_DELAY_MINUTES:5} +# Public HTTPS origin used when generating per-user Meta WhatsApp callback URLs. +flextuma.public-base-url=${FLEXTUMA_PUBLIC_BASE_URL:} + +# Base64-encoded 32-byte AES key. Required before connector credentials can be created or updated. +flextuma.connector-secrets.encryption-key=${FLEXTUMA_CONNECTOR_ENCRYPTION_KEY:} +# Set to 0 only for a deliberately unlimited plan. This cap applies before a shared system-connector message is charged. +flextuma.system-connectors.daily-message-limit-per-user=${FLEXTUMA_SYSTEM_CONNECTORS_DAILY_MESSAGE_LIMIT_PER_USER:1000} + # App Upload Directories flextuma.app.upload.directory=${APP_UPLOAD_DIRECTORY:/tmp/apps} flextuma.app.frontend.directory=${APP_FRONTEND_DIRECTORY:/tmp/frontend} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java index 57a55a5..9fb55e4 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java @@ -34,6 +34,7 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; +import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -82,6 +83,7 @@ class NotificationServiceTest { void setUp() { testUser = new User(); testUser.setUsername("testuser"); + testUser.setId(UUID.randomUUID()); validPlaceholders = new HashMap<>(); validPlaceholders.put("provider", "Twilio"); @@ -193,4 +195,44 @@ void queueTemplatedSms_shouldQueueSmsSuccessfully() { assertNotNull(result); } + + @Test + void queueRawSms_shouldDebitWalletAtPerSegmentPriceForSystemConnector() { + Map payload = new HashMap<>(); + payload.put("provider", "BEEM"); + payload.put("phoneNumber", "+255700000000"); + payload.put("message", "System connector message"); + SmsConnector systemConnector = new SmsConnector(); + systemConnector.setProvider("BEEM"); + systemConnector.setCode("BEEM_SYSTEM"); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "BEEM")).thenReturn(Optional.empty()); + when(connectorRepository.findByProviderAndCode("BEEM", "BEEM_SYSTEM")).thenReturn(Optional.of(systemConnector)); + when(segmentCalculator.calculate(anyString())).thenReturn(new SmsSegmentResult(2, true, 0, 0, BigDecimal.ONE, BigDecimal.ONE)); + when(logRepository.save(any(SmsLog.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + notificationService.queueRawSms(payload, "testuser"); + + verify(walletService).debit(eq(testUser), argThat(amount -> amount.compareTo(BigDecimal.valueOf(40)) == 0), contains("System connector BEEM"), isNull()); + } + + @Test + void queueRawSms_shouldRejectAnotherUsersConnectorId() { + Map payload = new HashMap<>(); + payload.put("provider", "BEEM"); + payload.put("phoneNumber", "+255700000000"); + payload.put("message", "Attempted misuse"); + UUID connectorId = UUID.randomUUID(); + payload.put("connectorId", connectorId.toString()); + User otherUser = new User(); otherUser.setId(UUID.randomUUID()); + SmsConnector connector = new SmsConnector(); connector.setProvider("BEEM"); connector.setCreatedBy(otherUser); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(connectorRepository.findByIdAndActiveTrue(connectorId)).thenReturn(Optional.of(connector)); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> notificationService.queueRawSms(payload, "testuser")); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + verifyNoInteractions(walletService); + } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java index 64af073..c8f752e 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java @@ -3,6 +3,8 @@ import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.entities.auth.User; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -29,11 +31,15 @@ class SmsLogServiceTest { @Mock private SmsLogRepository smsLogRepository; + @Mock + private CurrentUserResolver currentUserResolver; + @InjectMocks private SmsLogService smsLogService; private UUID logId; private SmsLog smsLog; + private User currentUser; @BeforeEach void setUp() { @@ -44,6 +50,10 @@ void setUp() { smsLog.setRetries(1); smsLog.setError("Network timeout"); smsLog.setProviderResponse(Map.of("error", "HTTP 504", "status", "failed")); + currentUser = new User(); + currentUser.setId(UUID.randomUUID()); + smsLog.setCreatedBy(currentUser); + lenient().when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(currentUser)); } @Test From 206115f5646c9cee95db733c60bb09f218001efb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 31 Aug 2026 14:44:40 +0000 Subject: [PATCH 2/2] Release v0.0.45 [skip ci] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 8ab7931..139e566 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.44' +version = '0.0.45' description = 'Flextuma App' java {